LearnOpenGL——高级OpenGL(上)

教程地址:简介 - LearnOpenGL CN


深度测试


坐标系统小节中,我们渲染了一个3D箱子,并利用深度缓冲(Depth Buffer)来正确处理遮挡关系,防止后面的面渲染到前面。本节我们将深入探讨深度缓冲(也称z缓冲(z-buffer))中存储的深度值(Depth Value),以及OpenGL如何使用它们来判断片段的前后关系。

基本概念

工作原理

深度缓冲就像颜色缓冲(Color Buffer)一样,存储每个片段的信息。它通常与颜色缓冲具有相同的宽度和高度,由窗口系统自动创建,并以16位、24位或32位浮点数的形式存储深度值。大多数系统使用24位深度缓冲。

当深度测试(Depth Testing)启用时,OpenGL会将片段的深度值与深度缓冲中的内容进行比较。如果深度测试通过,则更新深度缓冲为新的深度值;否则,丢弃该片段。

深度缓冲在片段着色器(Fragment Shader)运行之后、模板测试(Stencil Testing)运行之前(我们将在下一节讨论)在屏幕空间中进行。屏幕空间坐标与通过 glViewport 定义的视口密切相关,我们可以使用GLSL内建变量 gl_FragCoord 在片段着色器中直接访问。gl_FragCoord 的x和y分量表示片段的屏幕空间坐标((0, 0)为左下角),z分量则包含片段的真实深度值,该值将与深度缓冲中的内容进行比较。

提前深度测试

现在,大多数GPU都支持一种称为提前深度测试(Early Depth Testing)的硬件特性(OpenGL中使用深度测试会默认开启)。它允许深度测试在片段着色器运行之前执行。如果我们确定一个片段永远不可见(因为它在其他物体之后),就可以提前丢弃它。

由于片段着色器的开销通常很大,我们应该尽可能避免运行它们。使用提前深度测试时,片段着色器的一个限制是不能写入片段的深度值。因为提前深度测试需要依赖于固定的深度值来判断片段是否可见。如果片段着色器改变了这个深度值(gl_FragDepth),OpenGL(或任何使用的图形API)将无法提前准确地知道最终的深度值,进而无法执行有效的提前深度测试。

启用和清除深度缓冲

深度测试默认是禁用的,我们需要使用GL_DEPTH_TEST选项来启用它:

glEnable(GL_DEPTH_TEST);

启用后,如果片段通过深度测试,OpenGL会将该片段的z值存储在深度缓冲中;否则,丢弃该片段。如果启用了深度缓冲,还应该在每个渲染迭代之前使用GL_DEPTH_BUFFER_BIT清除深度缓冲,否则将使用前一次渲染迭代中写入的深度值:

glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);

只读深度缓冲

在某些情况下,我们可能需要对所有片段执行深度测试并丢弃不符合条件的片段,但不希望更新深度缓冲。这相当于使用只读的(Read-only)深度缓冲。OpenGL允许我们通过将深度掩码(Depth Mask)设置为 GL_FALSE 来禁用深度缓冲的写入:

glDepthMask(GL_FALSE);

深度测试函数

OpenGL 允许我们修改深度测试中使用的比较运算符,从而控制 OpenGL 何时通过或丢弃一个片段,以及何时更新深度缓冲。我们可以调用 glDepthFunc 函数来设置比较运算符(或称为深度函数(Depth Function)):

glDepthFunc(GL_LESS);

该函数接受下表中的比较运算符:

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

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

让我们看看改变深度函数会对视觉输出有什么影响。我们将使用一个新的代码配置,它会显示一个没有光照的基本场景,里面有两个有纹理的立方体,放置在一个有纹理的地板上。你可以在这里找到源代码。

在源代码中,我们将深度函数改为GL_ALWAYS

glEnable(GL_DEPTH_TEST);
glDepthFunc(GL_ALWAYS);

这将会模拟我们没有启用深度测试时所得到的结果。深度测试将会永远通过,所以最后绘制的片段将会总是会渲染在之前绘制片段的上面,即使之前绘制的片段本就应该渲染在最前面。因为我们是最后渲染地板的,它会覆盖所有的箱子片段:

image.png

将它重新设置为GL_LESS,这会将场景还原为原有的样子:

image.png

深度值精度

深度缓冲中存储的深度值介于 0.0 和 1.0 之间,用于与观察空间中所有物体的 z 值进行比较。观察空间的 z 值范围通常是投影平截头体的近平面(Near)和远平面(Far)之间的任意值。为了将这些观察空间的 z 值映射到[0, 1] 范围内,我们需要进行转换。一种简单的方法是线性变换:

F d e p t h = z − n e a r f a r − n e a r F_{depth} = \frac{z-near}{far-near} Fdepth=farnearznear

其中,nearfar 是我们之前在设置投影矩阵时(见坐标系统)提供的近平面和远平面距离。这个公式将平截头体内的 z 值转换为 0.0 到 1.0 之间的深度值。下图展示了 z 值与对应深度值之间的关系:

image.png

可以看到,通过上述公式计算出的深度值,越靠近近平面的物体深度值越接近 0.0,越靠近远平面的物体深度值越接近 1.0。

然而,在实际应用中,我们几乎不会使用线性深度缓冲。为了获得正确的投影效果,我们需要一个非线性的深度方程,它与 1/z 成正比。这样做的好处是,在 z 值很小(即物体离相机很近)时提供非常高的精度,而在 z 值很大(即物体离相机很远)时提供较低的精度。仔细思考一下:对于 1000 个单位远的深度值,和 1 个单位远的充满细节的物体,我们需要使用相同的精度吗?线性方程显然没有考虑到这一点。

由于非线性方程与 1/z 成正比,因此 1.0 和 2.0 之间的 z 值将转换为 1.0 到 0.5 之间的深度值。这占据了浮点数精度的一半,从而在 z 值很小的情况下提供了非常大的精度。而 50.0 和 100.0 之间的 z 值将只占浮点数精度的 2%,这正是我们所需要的。这样一个考虑了远近距离的方程是这样的:

F d e p t h = 1 / z − 1 / n e a r 1 / f a r − 1 / n e a r F_{depth} = \frac{1/z-1/near}{1/far-1/near} Fdepth=1/far1/near1/z1/near

如果你不理解这个方程的原理,也不必过于担心。重要的是要记住,深度缓冲中的值在屏幕空间中是非线性的(在应用透视矩阵之前,在观察空间中是线性的)。深度缓冲中 0.5 的值并不意味着物体的 z 值位于平截头体的中间。实际上,该顶点的 z 值非常接近近平面!你可以在下图中看到 z 值和最终深度缓冲值之间的非线性关系:

image.png

可以看到,深度值的大部分是由很小的 z 值决定的,这使得近处的物体拥有很高的深度精度。这个(从观察者视角)变换 z 值的方程嵌入在投影矩阵中,因此当我们想将一个顶点坐标从观察空间转换到裁剪空间时,这个非线性方程就会被应用。如果你想深度了解投影矩阵究竟做了什么,我建议阅读这篇文章

如果我们想要可视化深度缓冲的话,非线性方程的效果很快就会变得很清楚。


以下是透视投影矩阵的推导过程,建议观看 gams101 第四讲:Lecture 04 Transformation Cont._哔哩哔哩_bilibili

设近平面和远平面与原点的距离分别为 n 和 f,且 n > 0, f > 0, f > n。在三维空间中,我们用一个点 (x, y, z, 1) 来表示一个物体的位置。

过相机位置(原点)作一条直线,连接三维点 (x, y, z, 1)。这条直线与近平面相交于一点,该点即为 (x, y, z, 1) 在近平面上的投影。根据相似三角形原理,我们可以得到以下比例关系:

x ′ n = x − z \frac{x'}{n} = \frac{x}{-z} nx=zx
y ′ n = y − z \frac{y'}{n} = \frac{y}{-z} ny=zy

通过上述比例关系,我们可以解出投影点的坐标: x ′ = − n x / z x' = -nx / z x=nx/z y ′ = − n y / z y' = -ny / z y=ny/z,变换后的 z ′ z' z 暂时未知,因此,三维空间中的点 (x, y, z, 1) 透视变换后,投影到近平面上的坐标为 (-nx/z, -ny/z, unknown, 1)。为了方便后续计算,我们将投影坐标乘以 −z,得到 (nx,ny,unknown,−z)。

根据上述投影结果,我们可以初步确定变换矩阵的形式:

( n 0 0 0 0 n 0 0 ? ? ? ? 0 0 − 1 0 ) \begin{pmatrix} n & 0 & 0 & 0 \\ 0 & n & 0 & 0 \\ ? & ? & ? & ? \\ 0 & 0 & -1 & 0 \end{pmatrix} n0?00n?000?100?0

现在需要确定变换矩阵的第三行元素。点经过透视变换后,有三点性质可以利用:

  • 近平面上的点: (x,y,−n,1) 变换后仍位于近平面上,即 (x,y,−n,1),乘以 n 保持不变,得到 (nx,ny,−n2,n)。
  • 远平面中心的点: (0,0,−f,1) 变换后仍位于远平面中心上,即 (0,0,−f,1),乘以 f 保持不变,得到 (0,0,−f2,f)。
  • 远平面上的点: 远平面的所有点坐标z值不变 都是f

设矩阵第三行为 (0,0,A,B)。

  • 对于近平面点: −An+B=−n2
  • 对于远平面点: −Af+B=−f2

联立两式,解得 A=n+f,B=nf。结合以上推导,得到初步的透视变换矩阵:

( n 0 0 0 0 n 0 0 0 0 n + f n f 0 0 − 1 0 ) \begin{pmatrix} n & 0 & 0 & 0 \\ 0 & n & 0 & 0 \\ 0 & 0 & n+f & nf \\ 0 & 0 & -1 & 0 \end{pmatrix} n0000n0000n+f100nf0

然后再进行一次正交投影,首先平移中心点到原点,平移矩阵:

( 1 0 0 − l + r 2 0 1 0 − b + t 2 0 0 1 − n + f 2 0 0 0 1 ) \begin{pmatrix} 1 & 0 & 0 & -\frac{l+r}{2} \\ 0 & 1 & 0 & -\frac{b+t}{2} \\ 0 & 0 & 1 & -\frac{n+f}{2} \\ 0 & 0 & 0 & 1 \end{pmatrix} 1000010000102l+r2b+t2n+f1

然后,缩放平头截体到[-1, 1],缩放矩阵:

( 2 r − l 0 0 0 0 2 t − b 0 0 0 0 2 f − n 0 0 0 0 1 ) \begin{pmatrix} \frac{2}{r-l} & 0 & 0 & 0 \\ 0 & \frac{2}{t-b} & 0 & 0 \\ 0 & 0 & \frac{2}{f-n} & 0 \\ 0 & 0 & 0 & 1 \end{pmatrix} rl20000tb20000fn200001

正交投影矩阵 = 缩放矩阵 * 平移矩阵,由于视锥体的特殊性(r−l=2r,t−b=2t,r+l=0,t+b=0),正交投影矩阵可以简化为:

( 1 r 0 0 0 0 1 t 0 0 0 0 2 f − n n + f n − f 0 0 0 1 ) \begin{pmatrix} \frac{1}{r} & 0 & 0 & 0 \\ 0 & \frac{1}{t} & 0 & 0 \\ 0 & 0 & \frac{2}{f-n} & \frac{n+f}{n-f} \\ 0 & 0 & 0 & 1 \end{pmatrix} r10000t10000fn2000nfn+f1

最后将初步透视变换矩阵与正交投影矩阵相乘得到:

( n r 0 0 0 0 n t 0 0 0 0 n + f f − n 2 n f f − n 0 0 − 1 0 ) \begin{pmatrix} \frac{n}{r} & 0 & 0 & 0 \\ 0 & \frac{n}{t} & 0 & 0 \\ 0 & 0 & \frac{n+f}{f-n} & \frac{2nf}{f-n} \\ 0 & 0 & -1 & 0 \end{pmatrix} rn0000tn0000fnn+f100fn2nf0

到这里透视投影已经完成,但是由于opengl做默认深度测试时,深度值越小的点越靠近摄像机,而当前是深度值越大的点越靠近摄像机,所以需要乘(左乘)以一个镜面翻转矩阵

( 1 0 0 0 0 1 0 0 0 0 − 1 0 0 0 0 1 ) \begin{pmatrix} 1 & 0 & 0 & 0 \\ 0 & 1 & 0 & 0 \\ 0 & 0 & -1 & 0 \\ 0 & 0 & 0 & 1 \end{pmatrix} 1000010000100001

最终的透视投影矩阵为:

( n r 0 0 0 0 n t 0 0 0 0 n + f n − f 2 n f n − f 0 0 − 1 0 ) \begin{pmatrix} \frac{n}{r} & 0 & 0 & 0 \\ 0 & \frac{n}{t} & 0 & 0 \\ 0 & 0 & \frac{n+f}{n-f} & \frac{2nf}{n-f} \\ 0 & 0 & -1 & 0 \end{pmatrix} rn0000tn0000nfn+f100nf2nf0


深度缓冲的可视化

我们知道在片段着色器中,内置的 gl_FragCoord 向量的 z 分量包含了该特定片段的深度值。如果我们将这个深度值输出为颜色,就可以显示场景中所有片段的深度值。我们可以通过根据片段的深度值返回一个颜色向量来完成这项工作:

void main() {
    FragColor = vec4(vec3(gl_FragCoord.z), 1.0);
}

如果你再次运行程序,你可能会注意到所有物体都是白色的,看起来就像我们所有的深度值都是最大的 1.0。那么为什么没有靠近 0.0(即变暗)的深度值呢?

你可能还记得在上一部分中提到,屏幕空间中的深度值是非线性的,也就是说,它在 z 值很小的时候有很高的精度,而在 z 值很大的时候精度较低。片段的深度值会随着距离迅速增加,因此几乎所有顶点的深度值都接近于 1.0。如果我们小心地靠近物体,你最终可能会注意到颜色会逐渐变暗,显示它们的 z 值在逐渐变小:

image.png

很明显,近处的物体对深度值的影响比远处的物体要大得多。仅仅移动几厘米,颜色就可以从完全变暗到完全变白。

但是,我们也可以将片段的非线性深度值转换为线性深度值。要做到这一点,我们需要逆转深度值的投影变换。这意味着我们需要首先将深度值从 [0, 1] 范围重新变换到 [-1, 1] 范围的标准化设备坐标(裁剪空间)。然后,我们需要像投影矩阵那样反转这个非线性方程,并将这个反转的方程应用到最终的深度值上。最终的结果就是一个线性的深度值。听起来是可行的,对吧?

首先我们将深度值变换为NDC,不是非常困难:

float ndc = depth * 2.0 - 1.0;

接下来使用获取到的NDC值,应用逆变换来获取线性的深度值:

float linearDepth = (2.0 * near * far) / (far + near - ndc * (far - near));

逆变换推导:

( x , y , z , 1 ) (x, y, z, 1) (x,y,z,1) 变换后为 ( n x r , n y t , ( n + f ) z ( n − f ) + 2 n f ( n − f ) , z ) \left(\frac{nx}{r}, \frac{ny}{t}, \frac{(n+f)z}{(n-f)} + \frac{2nf}{(n-f)}, z\right) (rnx,tny,(nf)(n+f)z+(nf)2nf,z)

进行齐次除法后得到 ( − n z z r , − n y z t , ( n + f ) ( f − n ) + 2 n f z ( f − n ) , 1 ) \left(-\frac{nz}{zr}, -\frac{ny}{zt}, \frac{(n+f)}{(f-n)} + \frac{2nf}{z(f-n)}, 1\right) (zrnz,ztny,(fn)(n+f)+z(fn)2nf,1)。求解过程如下:

z → z ′ = ( n + f ) ( f − n ) + 2 n f z ( f − n ) z \rightarrow z' = \frac{(n+f)}{(f-n)} + \frac{2nf}{z(f-n)} zz=(fn)(n+f)+z(fn)2nf

z ′ − ( n + f ) ( f − n ) = 2 n f z ( f − n ) z' - \frac{(n+f)}{(f-n)} = \frac{2nf}{z(f-n)} z(fn)(n+f)=z(fn)2nf

( z ′ − ( n + f ) ( f − n ) ) ⋅ ( f − n ) 2 n f = 1 z \left(z' - \frac{(n+f)}{(f-n)}\right) \cdot \frac{(f-n)}{2nf} = \frac{1}{z} (z(fn)(n+f))2nf(fn)=z1

( z ′ ⋅ ( f − n ) − ( n + f ) ) 2 n f = 1 z \frac{(z' \cdot (f-n) - (n+f))}{2nf} = \frac{1}{z} 2nf(z(fn)(n+f))=z1

2 n f ⋅ 1 ( z ′ ⋅ ( f − n ) − ( n + f ) ) = z 2nf \cdot \frac{1}{(z' \cdot (f-n) - (n+f))} = z 2nf(z(fn)(n+f))1=z

由于 z z z 是负数,因此需要取反才能作为颜色值,从而得到:

− z = 2 n f ( n + f − z ′ ⋅ ( f − n ) ) -z = \frac{2nf}{(n+f - z' \cdot (f-n))} z=(n+fz(fn))2nf


这个方程是用投影矩阵推导得出的,它使用了方程2来非线性化深度值,返回一个near与far之间的深度值。这篇注重数学的文章为感兴趣的读者详细解释了投影矩阵,它也展示了这些方程是怎么来的。

将屏幕空间中非线性的深度值变换至线性深度值的完整片段着色器如下:

#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; // 转换为 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);
}

LinearizeDepth(gl_FragCoord.z) / far 这种方法获得的深度值位于 [ n / f , 1 ] [n/f, 1] [n/f,1] 之间。如果要将深度值归一化到 [ 0 , 1 ] [0, 1] [0,1] 范围内,正确的方法是 ( z − n ) ( f − n ) \frac{(z - n)}{(f - n)} (fn)(zn)。教程中的代码写法有误,正确的代码应该是:(LinearizeDepth(gl_FragCoord.z) - near) / (far - near);


由于线性化的深度值处于near与far之间,它的大部分值都会大于1.0并显示为完全的白色。通过在main函数中将线性深度值除以far,我们近似地将线性深度值转化到[0, 1]的范围之间。这样子我们就能逐渐看到一个片段越接近投影平截头体的远平面,它就会变得越亮,更适用于展示目的。

如果我们现在运行程序,我们就能看见深度值随着距离增大是线性的了。尝试在场景中移动,看看深度值是怎样以线性变化的。

image.png

颜色大部分都是黑色,因为深度值的范围是0.1的平面到100的平面,它离我们还是非常远的。结果就是,我们相对靠近近平面,所以会得到更低的(更暗的)深度值。

深度冲突

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

在我们一直使用的场景中,有几个地方的深度冲突非常明显。箱子被放置在地板的同一高度上,这意味着箱子的底面和地板是共面的(Coplanar)。这两个面的深度值相同,因此深度测试无法决定应该显示哪一个。

如果你将摄像机移动到其中一个箱子的内部,你就能清楚地看到这种效果。箱子的底部不断地在箱子底面和地板之间切换,形成一个锯齿状的花纹:

PixPin_2025-02-11_19-29-26.gif

深度冲突是深度缓冲中一个常见的问题,当物体位于远处时,效果会更加明显(因为深度缓冲在 z 值较大时精度较低)。深度冲突无法完全避免,但通常有一些技巧可以帮助减轻或完全避免场景中的深度冲突。

防止深度冲突

  1. 避免物体过于接近:
    • 第一个也是最重要的技巧是,永远不要将多个物体摆放得太近,以至于它们的某些三角形会重叠。
    • 通过在两个物体之间设置一个用户无法察觉到的微小偏移值,可以完全避免这两个物体之间的深度冲突。
    • 在箱子和地板的例子中,我们可以将箱子沿着正 y 轴稍微移动一点。箱子位置的这一点微小改变不太可能被注意到,但它可以完全减少深度冲突的发生。
    • 然而,这需要对每个物体都手动调整,并且需要进行彻底的测试来保证场景中没有物体会产生深度冲突。
  2. 调整近平面距离:
    • 第二个技巧是尽可能将近平面设置得远一些。
    • 在前面我们提到过,精度在靠近近平面时非常高,因此如果我们让近平面远离观察者,我们将对整个平截头体拥有更大的精度。
    • 然而,将近平面设置得太远会导致近处的物体被裁剪掉,因此这通常需要实验和微调来决定最适合你的场景的近平面距离。
  3. 使用更高精度的深度缓冲:
    • 另一个很好的技巧是牺牲一些性能,使用更高精度的深度缓冲。
    • 大部分深度缓冲的精度都是 24 位的,但现在大部分的显卡都支持 32 位的深度缓冲,这将极大地提高精度。
    • 因此,牺牲掉一些性能,你就能获得更高精度的深度测试,减少深度冲突。

本次项目源码:深度测试 - GitCode


模板测试


当片段着色器处理完一个片段后,模板测试(Stencil Test) 就会开始执行。与深度测试类似,模板测试也可能丢弃片段。接下来,通过模板测试的片段会进入深度测试,深度测试可能会进一步丢弃一些片段。模板测试是基于另一个缓冲区进行的,它被称为模板缓冲(Stencil Buffer)。我们可以在渲染时更新模板缓冲区,以获得一些有趣的效果。

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

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

模板缓冲的一个简单的例子如下:

image.png

模板缓冲首先会被清除为0,之后在模板缓冲中使用1填充了一个空心矩形。场景中的片段将会只在片段的模板值为1的时候会被渲染(其它的都被丢弃了)。

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

  1. 启用模板缓冲的写入: 允许修改模板缓冲区的内容。
  2. 渲染物体,更新模板缓冲内容: 绘制物体,并根据需要更新模板缓冲区中对应位置的模板值。
  3. 禁用模板缓冲的写入: 禁止修改模板缓冲区的内容,以防止后续渲染意外修改已写入的模板值。
  4. 渲染(其他)物体,根据模板缓冲内容丢弃特定片段: 绘制其他物体,并根据模板缓冲区中存储的模板值,决定是否丢弃某些片段。

因此,通过使用模板缓冲区,我们可以根据场景中已绘制的其他物体的片段,来决定是否丢弃特定的片段。

你可以通过启用 GL_STENCIL_TEST 来启用模板测试。在这行代码之后,所有的渲染调用都会以某种方式影响模板缓冲区。

glEnable(GL_STENCIL_TEST);

注意,和颜色缓冲与深度缓冲一样,你也需要在每次迭代之前清除模板缓冲区。

glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT | GL_STENCIL_BUFFER_BIT);

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

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

大部分情况下,你都只会使用 0x00 或者 0xFF 作为模板掩码(Stencil Mask),但是知道有选项可以设置自定义的位掩码总是好的。

模板函数

和深度测试一样,我们可以控制模板缓冲应该通过还是失败,以及它应该如何影响模板缓冲。一共有两个函数可以用来配置模板测试:glStencilFuncglStencilOp

glStencilFunc(GLenum func, GLint ref, GLuint mask) 模板测试函数,指定什么情况下通过模板测试,一共包含三个参数:

  • func:设置模板测试函数(Stencil Test Function)。这个测试函数将会应用到已存储的模板值上和 glStencilFunc 函数的 ref 值上。可用的选项有:GL_NEVERGL_LESSGL_LEQUALGL_GREATERGL_GEQUALGL_EQUALGL_NOTEQUALGL_ALWAYS。它们的语义和深度缓冲的函数类似。
  • ref:设置了模板测试的参考值(Reference Value)。模板缓冲区的内容将会与这个值进行比较。
  • mask:设置一个掩码,mask会与 ref 和缓冲的模板值分别进行 And 运算,初始情况下 mask 所有位都为 1。
  • 模板测试比较时,首先将掩码值(mask)与参考值(ref)进行按位与运算,得到一个中间结果。然后,将当前模板中的值(stencil)与掩码值(mask)进行按位与运算,得到另一个中间结果。最后,将这两个中间结果与参考函数(func)进行比较,以确定是否可以通过测试。
    • 例如,当使用 GL_LESS 作为比较函数时,只有当 (stencil & mask) < (ref & mask) 时,测试才能通过。同样,当使用 GL_GEQUAL 作为比较函数时,只有当 (stencil & mask) >= (ref & mask) 时,测试才能通过。

在一开始的那个简单的模板例子中,函数被设置为:

glStencilFunc(GL_EQUAL, 1, 0xFF)

这会告诉OpenGL,只要一个片段的模板值等于(GL_EQUAL)参考值1,片段将会通过测试并被绘制,否则会被丢弃。


glStencilFunc 函数仅指定了 OpenGL 如何比较模板缓冲区中的内容,但并未定义如何更新缓冲区。要控制模板缓冲区的更新方式,我们需要使用 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)的,所以不论任何测试的结果是如何,模板缓冲都会保留它的值。默认的行为不会更新模板缓冲,所以如果你想写入模板缓冲的话,你需要至少对其中一个选项设置不同的值。

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

物体轮廓

仅仅看了前面的部分你还是不太可能能够完全理解模板测试的工作原理,所以我们将会展示一个使用模板测试就可以完成的有用特性,它叫做物体轮廓(Object Outlining)。

image.png

物体轮廓,顾名思义,就是在物体周围创建一层有色的边框。这种效果在策略游戏中选中单位时非常有用,可以清晰地告知玩家当前选中的是哪个单位。创建物体轮廓的步骤如下:

  1. 启用模板写入: 开启模板缓冲的写入功能,以便后续步骤可以修改模板缓冲区的内容。
  2. 设置模板函数与更新策略: 在绘制物体之前,将模板函数设置为 GL_ALWAYS,更新策略为 GL_REPLACE
  3. 渲染物体: 正常渲染需要添加轮廓的物体,此时每个被渲染的片段都会将模板缓冲区的值更新为 1。
  4. 禁用模板写入和深度测试: 关闭模板缓冲的写入功能,并禁用深度测试。(ps:这里禁用深度测试会导致轮廓始终显示,让放大的箱子,即边框,不会被地板所覆盖。)
  5. 缩放物体: 将每个需要绘制轮廓的物体稍微放大一点点。
  6. 绘制轮廓: 使用一个不同的片段着色器,输出单一的轮廓颜色。再次绘制放大后的物体,但此时只绘制模板值 不等于 1 的片段。由于原物体的模板值已经被设置为 1,因此只有放大后超出原物体边缘的部分(即轮廓)会被绘制出来。
  7. 恢复设置: 重新启用模板写入和深度测试,以便后续的渲染操作不受影响。

这个过程首先将原物体的每个片段的模板缓冲值都设置为 1,然后绘制一个稍微放大后的物体。由于模板测试的设置,只有放大后超出原物体边缘的片段(即轮廓部分)才会被绘制,从而形成物体的轮廓效果。

所以我们首先来创建一个很简单的片段着色器,它会输出一个边框颜色。我们简单地给它设置一个硬编码的颜色值,将这个着色器命名为shaderSingleColor:

void main()
{
    FragColor = vec4(0.04, 0.28, 0.26, 1.0);
}

我们只希望给两个箱子添加边框,而地板则不参与这个过程。因此,我们的绘制顺序是:先绘制地板,然后绘制两个箱子(并写入模板缓冲区),最后绘制放大的箱子(此时会丢弃与之前绘制的箱子片段重叠的部分)。具体步骤如下:

  1. 启用模板测试:
    glEnable(GL_STENCIL_TEST);
    
  2. 设置模板操作:
    glStencilOp(GL_KEEP, GL_KEEP, GL_REPLACE);
    

    如果其中的一个测试失败了,我们什么都不做,我们仅仅保留当前储存在模板缓冲中的值。如果模板测试和深度测试都通过了,那么我们希望将储存的模板值设置为参考值,参考值能够通过glStencilFunc来设置,我们之后会设置为1。

  3. 清除模板缓冲区:
    glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT | GL_STENCIL_BUFFER_BIT);
    
    在开始绘制之前,我们需要将模板缓冲区清除为 0。
  4. 绘制地板:
    glStencilMask(0x00); // 禁止写入模板缓冲区,因为我们不希望地板影响模板缓冲区
    normalShader.use();
    DrawFloor();
    
  5. 绘制箱子并写入模板缓冲区:
    glStencilFunc(GL_ALWAYS, 1, 0xFF); // 所有片段都通过模板测试,并将模板值设置为 1
    glStencilMask(0xFF); // 启用模板缓冲区写入
    normalShader.use();
    DrawTwoContainers();
    
    通过使用 GL_REPLACE 模板操作,我们确保箱子的每个片段都会将模板缓冲区的值更新为 1。
  6. 绘制放大的箱子(轮廓):
    glStencilFunc(GL_NOTEQUAL, 1, 0xFF); // 只绘制模板值不为 1 的片段(即轮廓部分)
    glStencilMask(0x00); // 禁止写入模板缓冲区
    glDisable(GL_DEPTH_TEST); // 禁用深度测试,防止轮廓被地板遮挡
    shaderSingleColor.use();
    DrawTwoScaledUpContainers();
    glEnable(GL_DEPTH_TEST); // 重新启用深度测试
    glStencilMask(0xFF); // 恢复模板缓冲区写入
    
    这里我们将模板函数设置为 GL_NOTEQUAL,这样就只会绘制模板值不为 1 的片段,也就是放大后超出原箱子边缘的部分,即轮廓。同时,我们禁用了深度测试,以防止轮廓被地板遮挡。

场景中物体轮廓的完整步骤会看起来像这样:

glEnable(GL_DEPTH_TEST);
glStencilOp(GL_KEEP, GL_KEEP, GL_REPLACE);  

glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT | GL_STENCIL_BUFFER_BIT); 

glStencilMask(0x00); // 记得保证我们在绘制地板的时候不会更新模板缓冲
normalShader.use();
DrawFloor()  

glStencilFunc(GL_ALWAYS, 1, 0xFF); 
glStencilMask(0xFF); 
DrawTwoContainers();

glStencilFunc(GL_NOTEQUAL, 1, 0xFF);
glStencilMask(0x00); 
glDisable(GL_DEPTH_TEST);
shaderSingleColor.use(); 
DrawTwoScaledUpContainers();
glStencilMask(0xFF);
glEnable(GL_DEPTH_TEST);  

只要你理解了模板缓冲背后的大体思路,这个代码片段就不是那么难理解了。如果还是不能理解的话,尝试再次仔细阅读之前的部分,并尝试通过上面使用的范例,完全理解每个函数的功能。

深度测试小节的场景中,这个轮廓算法的结果看起来会像是这样的:

image.png

可以在这里查看源代码,看看物体轮廓算法的完整代码。

你可以看到这两个箱子的边框重合了,这通常都是我们想要的结果(想想策略游戏中,我们希望选择10个单位,合并边框通常是我们想需要的结果)。如果你想让每个物体都有一个完整的边框,你需要对每个物体都清空模板缓冲,并有创意地利用深度缓冲。


下面介绍如何实现让每个物体都有一个完整的边框。要想实现每个物体都有单独的边框,就需要在绘制物体之后马上绘制物体的边框,然后清除模板缓冲区。

以下代码绘制了 10 个箱子:

// 1nd 绘制箱子
glStencilFunc(GL_ALWAYS, 1, 0xFF);
glStencilMask(0xFF);
glBindVertexArray(cubeVAO);
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, cubeTexture);
for (unsigned int i = 0; i < 10; i++)
{
	model = glm::mat4(1.f);
	model = glm::translate(model, cubePositions[i]);
	float angle = 20.0f * i;
	model = glm::rotate(model, glm::radians(angle), glm::vec3(1.0f, 0.3f, 0.5f));
	normalShader.setMat4("model", model);
	glDrawArrays(GL_TRIANGLES, 0, 36);
}

// 2nd 绘制箱子的轮廓
glStencilFunc(GL_NOTEQUAL, 1, 0xFF);
glStencilMask(0x00);
//glDisable(GL_DEPTH_TEST);
outlineShader.use();
float scale = 1.1f;
glBindVertexArray(cubeVAO);
for (unsigned int i = 0; i < 10; i++)
{
	model = glm::mat4(1.f);
	model = glm::translate(model, cubePositions[i]);
	model = glm::scale(model, glm::vec3(scale, scale, scale));
	float angle = 20.0f * i;
	model = glm::rotate(model, glm::radians(angle), glm::vec3(1.0f, 0.3f, 0.5f));
	outlineShader.setMat4("model", model);

	glDrawArrays(GL_TRIANGLES, 0, 36);
}
glBindVertexArray(0);
glStencilMask(0xFF);
glStencilFunc(GL_ALWAYS, 0, 0xFF);
//glEnable(GL_DEPTH_TEST);

image.png

需要做的就是将 1nd 与 2nd 放入同一个循环中进行,参考代码如下:

glBindVertexArray(cubeVAO);
for (unsigned int i = 0; i < 10; i++)
{
	// 1nd 绘制箱子
	normalShader.use();
	glStencilFunc(GL_ALWAYS, 1, 0xFF);
	glStencilMask(0xFF);
	glActiveTexture(GL_TEXTURE0);
	glBindTexture(GL_TEXTURE_2D, cubeTexture);
	model = glm::mat4(1.f);
	model = glm::translate(model, cubePositions[i]);
	float angle = 20.0f * i;
	model = glm::rotate(model, glm::radians(angle), glm::vec3(1.0f, 0.3f, 0.5f));
	normalShader.setMat4("model", model);
	glDrawArrays(GL_TRIANGLES, 0, 36);

	// 2nd 绘制箱子的轮廓
	outlineShader.use();
	glStencilFunc(GL_NOTEQUAL, 1, 0xFF);
	glStencilMask(0x00);
	float scale = 1.1f;
	model = glm::scale(model, glm::vec3(scale, scale, scale));
	outlineShader.setMat4("model", model);
	glDrawArrays(GL_TRIANGLES, 0, 36);

	glStencilMask(0xFF);
	glClear(GL_STENCIL_BUFFER_BIT);
}

需要注意的是 glStencilMask(0x00) 不仅会阻止模板缓冲的写入,也会阻止其清空 glClear(stencil_buffer) 无效,同时注意这里不要禁用深度测试,效果如下:

image.png


你看到的物体轮廓算法在需要显示选中物体的游戏(想想策略游戏)中非常常见。这样的算法能够在一个模型类中轻松实现。你可以在模型类中设置一个boolean标记,来设置需不需要绘制边框。如果你有创造力的话,你也可以使用后期处理滤镜(Filter),像是高斯模糊(Gaussian Blur),让边框看起来更自然。

除了物体轮廓之外,模板测试还有很多用途,比如在一个后视镜中绘制纹理,让它能够绘制到镜子形状中,或者使用一个叫做阴影体积(Shadow Volume)的模板缓冲技术渲染实时阴影。模板缓冲为我们已经很丰富的OpenGL工具箱又提供了一个很好的工具。

本次项目源码:模板测试 - GitCode


混合


OpenGL中,混合(Blending)通常是实现物体透明度(Transparency)的一种技术。透明就是说一个物体(或者其中的一部分)不是纯色(Solid Color)的,它的颜色是物体本身的颜色和它背后其它物体的颜色的不同强度结合。一个有色玻璃窗是一个透明的物体,玻璃有它自己的颜色,但它最终的颜色还包含了玻璃之后所有物体的颜色。这也是混合这一名字的出处,我们混合(Blend)(不同物体的)多种颜色为一种颜色。所以透明度能让我们看穿物体。

image.png

透明的物体可以是完全透明的(让所有的颜色穿过),或者是半透明的(它让颜色通过,同时也会显示自身的颜色)。一个物体的透明度是通过它颜色的alpha值来决定的。Alpha颜色值是颜色向量的第四个分量,你可能已经看到过它很多遍了。在这个教程之前我们都将这个第四个分量设置为1.0,让这个物体的透明度为0.0,而当alpha值为0.0时物体将会是完全透明的。当alpha值为0.5时,物体的颜色有50%是来自物体自身的颜色,50%来自背后物体的颜色。

我们目前一直使用的纹理有三个颜色分量:红、绿、蓝。但一些材质会有一个内嵌的alpha通道,对每个纹素(Texel)都包含了一个alpha值。这个alpha值精确地告诉我们纹理各个部分的透明度。比如说,下面这个窗户纹理中的玻璃部分的alpha值为0.25(它在一般情况下是完全的红色,但由于它有75%的透明度,能让很大一部分的网站背景颜色穿过,让它看起来不那么红了),角落的alpha值是0.0。

我们很快就会将这个窗户纹理添加到场景中,但是首先我们需要讨论一个更简单的技术,来实现只有完全透明和完全不透明的纹理的透明度。

丢弃片段

有些图片并不需要半透明,只需要根据纹理颜色值,显示一部分,或者不显示一部分,没有中间情况。比如说草,如果想不太费劲地创建草这种东西,你需要将一个草的纹理贴在一个2D四边形(Quad)上,然后将这个四边形放到场景中。然而,草的形状和2D四边形的形状并不完全相同,所以你只想显示草纹理的某些部分,而忽略剩下的部分。

下面这个纹理正是这样的,它要么是完全不透明的(alpha值为1.0),要么是完全透明的(alpha值为0.0),没有中间情况。你可以看到,只要不是草的部分,这个图片显示的都是网站的背景颜色而不是它本身的颜色。

image.png

在场景中添加诸如草之类的植被时,我们不希望看到草的方形图像,而是只显示草的部分,并能看透图像其余的部分。这意味着我们需要 丢弃(Discard) 纹理中透明部分的片段,并且不将这些片段存储在颜色缓冲区中。

在此之前,我们需要先学习如何加载透明纹理。加载带有 alpha 值的纹理并不需要做太多改动。stb_image 库在纹理有 alpha 通道时会自动加载,但我们仍然需要在纹理生成过程中告诉 OpenGL,我们的纹理现在使用 alpha 通道:

glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, width, height, 0, GL_RGBA, GL_UNSIGNED_BYTE, data);

同样,要确保在片段着色器中获取了纹理的全部四个颜色分量,而不仅仅是 RGB 分量:

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

既然我们已经知道如何加载透明纹理了,是时候将其应用到实践中了。我们将在深度测试小节的场景中添加一些草。

我们将创建一个 vector,并在其中添加几个 glm::vec3 变量来表示草的位置:

std::vector<glm::vec3> vegetation;
vegetation.push_back(glm::vec3(-1.5f,  0.0f, -0.48f));
vegetation.push_back(glm::vec3( 1.5f,  0.0f,  0.51f));
vegetation.push_back(glm::vec3( 0.0f,  0.0f,  0.7f));
vegetation.push_back(glm::vec3(-0.3f,  0.0f, -2.3f));
vegetation.push_back(glm::vec3( 0.5f,  0.0f, -0.6f));

每棵草都渲染到一个四边形上,并贴上草的纹理。这并不能完美地表现 3D 的草,但它比加载复杂的模型要快得多。通过使用一些小技巧,例如在同一位置添加一些旋转后的草四边形,你仍然可以获得比较好的效果。

由于草的纹理是添加到四边形对象上的,我们还需要创建另一个 VAO,填充 VBO,并设置正确的顶点属性指针。

float transparentVertices[] = {
	// positions         // texture Coords (swapped y coordinates because texture is flipped upside down)
	0.0f,  0.5f,  0.0f,  0.0f,  0.0f,
	0.0f, -0.5f,  0.0f,  0.0f,  1.0f,
	1.0f, -0.5f,  0.0f,  1.0f,  1.0f,

	0.0f,  0.5f,  0.0f,  0.0f,  0.0f,
	1.0f, -0.5f,  0.0f,  1.0f,  1.0f,
	1.0f,  0.5f,  0.0f,  1.0f,  0.0f
};

// transparent VAO
unsigned int vegetationVAO, vegetationVBO;
glGenVertexArrays(1, &vegetationVAO);
glGenBuffers(1, &vegetationVBO);
glBindVertexArray(vegetationVAO);
glBindBuffer(GL_ARRAY_BUFFER, vegetationVBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(transparentVertices), transparentVertices, GL_STATIC_DRAW);
glEnableVertexAttribArray(0);
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 5 * sizeof(float), (void*)0);
glEnableVertexAttribArray(1);
glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, 5 * sizeof(float), (void*)(3 * sizeof(float)));
glBindVertexArray(0);

接下来,在绘制完地板和两个立方体后,我们将绘制草:

glBindVertexArray(vegetationVAO);
glBindTexture(GL_TEXTURE_2D, grassTexture);
for (unsigned int i = 0; i < vegetation.size(); i++) {
    model = glm::mat4(1.0f);
    model = glm::translate(model, vegetation[i]);
    shader.setMat4("model", model);
    glDrawArrays(GL_TRIANGLES, 0, 6);
}

运行程序后,你将看到:

image.png

之所以会出现草图块状显示的问题,是因为 OpenGL 默认情况下不知道如何处理 alpha 值,也不知道什么时候应该丢弃片段。我们需要手动进行处理。幸运的是,有了着色器,这非常容易实现。GLSL 提供了 discard 命令,一旦被调用,它就会确保片段不会被进一步处理,因此也不会进入颜色缓冲区。有了这个指令,我们就可以在片段着色器中检测片段的 alpha 值是否低于某个阈值,如果是,则丢弃该片段,就好像它不存在一样:

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

在这里,我们检测采样纹理颜色的 alpha 值是否低于 0.1 的阈值,如果是,则丢弃该片段。片段着色器确保它只会渲染不是(几乎)完全透明的片段。现在,草图的显示就正常了。

image.png

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

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

这种环绕方式会将纹理坐标限制在 [0, 1] 范围内,超出范围的坐标会被“钳制”到边缘。这意味着纹理边缘的像素会被拉伸到超出范围的区域,从而避免了与重复纹理进行插值的问题。

你可以在这里找到源码。

混合

虽然直接丢弃片段可以实现完全透明的效果,但它无法渲染半透明的图像。因为我们只能选择渲染一个片段或完全丢弃它。要渲染具有多个透明度级别的图像,我们需要启用混合(Blending)。与 OpenGL 的大多数功能一样,我们可以通过启用 GL_BLEND 来启用混合:

glEnable(GL_BLEND);

启用混合后,我们需要告诉 OpenGL 如何进行混合。

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

C ‾ r e s u l t = C ‾ s o u r c e ∗ F s o u r c e + C ‾ d e s t i n a t i o n ∗ F d e s t i n a t i o n \overline{C}_{result}=\overline{C}_{source}*F_{source}+\overline{C}_{destination}*F_{destination} Cresult=CsourceFsource+CdestinationFdestination

  • C ‾ s o u r c e \overline{C}_{source} Csource:源颜色向量。当前片元的颜色(即正在绘制的片元的颜色)
  • C ‾ d e s t i n a t i o n \overline{C}_{destination} Cdestination:目标颜色向量。颜色缓冲区中已存在的颜色(即之前绘制的颜色)
  • F s o u r c e F_{source} Fsource:源因子值。
  • F d e s t i n a t i o n F_{destination} Fdestination:目标因子值。

片段着色器运行完成后,并且所有测试都通过之后,此混合方程才会应用到片段颜色输出以及当前颜色缓冲区中的值(即当前片段之前存储的片段颜色)上。源颜色和目标颜色将由 OpenGL 自动设置,但源因子和目标因子的值由我们决定。我们先来看一个简单的例子:

image.png

假设我们有两个方形,我们希望将半透明的绿色方形绘制在红色方形之上。红色方形将是目标颜色(因此它应该先在颜色缓冲区中),我们将在红色方形之上绘制绿色方形。

现在的问题是:我们应该将因子值设置为什么?我们至少想让绿色方形乘以它的 alpha 值,因此我们希望将 F s o u r c e F_{source} Fsource 设置为源颜色向量的 alpha 值,也就是 0.6。接下来应该很清楚,目标方形的贡献应该是剩余的 alpha 值。如果绿色方形对最终颜色贡献了 60%,那么红色方形应该对最终颜色贡献 40%,即 1.0 - 0.6。因此我们将 F d e s t i n a t i o n F_{destination} Fdestination 设置为 1 减去源颜色向量的 alpha 值。该等式变为:

C ˉ r e s u l t = ( 0.0 1.0 0.0 0.6 ) ∗ 0.6 + ( 1.0 0.0 0.0 1.0 ) ∗ ( 1 − 0.6 ) \bar{C}_{result} = \begin{pmatrix} 0.0 \\ 1.0 \\ 0.0 \\ 0.6 \end{pmatrix} * 0.6 + \begin{pmatrix} 1.0 \\ 0.0 \\ 0.0 \\ 1.0 \end{pmatrix} * (1 - 0.6) Cˉresult= 0.01.00.00.6 0.6+ 1.00.00.01.0 (10.6)

结果是重叠方形的片段包含 60% 的绿色和 40% 的红色,呈现出一种混合颜色:

image.png

这样效果很好,但我们如何让 OpenGL 使用这样的因子呢?正好有一个专门的函数,叫做 glBlendFunc

glBlendFunc(GLenum sfactor, GLenum dfactor) 函数接受两个参数,用于设置源因子和目标因子。OpenGL 为我们定义了许多选项,我们将在下面列出最常用的选项。注意,常数颜色向量 C ‾ c o n s t a n t \overline{C}_{constant} Cconstant 可以通过 glBlendColor 函数进行设置。

选项
GL_ZERO因子等于0
GL_ONE因子等于1
GL_SRC_COLOR因子等于源颜色向量 C ‾ s o u r c e \overline{C}_{source} Csource
GL_ONE_MINUS_SRC_COLOR因子等于 1 − C ‾ s o u r c e 1−\overline{C}_{source} 1Csource
GL_DST_COLOR因子等于目标颜色向量 C ‾ d e s t i n a t i o n \overline{C}_{destination} Cdestination
GL_ONE_MINUS_DST_COLOR因子等于 1 − C ‾ d e s t i n a t i o n 1-\overline{C}_{destination} 1Cdestination
GL_SRC_ALPHA因子等于 C ‾ s o u r c e \overline{C}_{source} Csource 的alpha分量
GL_ONE_MINUS_SRC_ALPHA因子等于 1 − C ‾ s o u r c e 1-\overline{C}_{source} 1Csource 的alpha分量
GL_DST_ALPHA因子等于 C ‾ d e s t i n a t i o n \overline{C}_{destination} Cdestination 的alpha分量
GL_ONE_MINUS_DST_ALPHA因子等于 1 − C ‾ d e s t i n a t i o n 1-\overline{C}_{destination} 1Cdestination 的alpha分量
GL_CONSTANT_COLOR因子等于常数颜色向量 C ‾ c o n s t a n t \overline{C}_{constant} Cconstant
GL_ONE_MINUS_CONSTANT_COLOR因子等于 1 − C ‾ c o n s t a n t 1-\overline{C}_{constant} 1Cconstant
GL_CONSTANT_ALPHA因子等于 C ‾ c o n s t a n t \overline{C}_{constant} Cconstant 的alpha分量
GL_ONE_MINUS_CONSTANT_ALPHA因子等于 1 − C ‾ c o n s t a n t 1-\overline{C}_{constant} 1Cconstant 的alpha分量

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

glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);

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

// void glBlendFuncSeparate(GLenum srcRGB, GLenum dstRGB, GLenum srcAlpha, GLenum dstAlpha)
glBlendFuncSeparate(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA, GL_ONE, GL_ZERO);

这个函数用与之前设置的相同方式设置了 RGB 分量,但最终的 alpha 分量只受到源颜色向量的 alpha 值的影响。

OpenGL 甚至为我们提供了更大的灵活性,允许我们更改方程中源和目标之间的运算符。默认情况下,源和目标是相加的,但若有需要,我们也可以让它们相减。glBlendEquation(GLenum mode) 允许我们设置运算符,它提供了三个选项:

  • GL_FUNC_ADD:默认选项,将两个分量相加: C ‾ r e s u l t = S r c + D s t \overline{C}_{result} = Src + Dst Cresult=Src+Dst
  • GL_FUNC_SUBTRACT:将两个分量相减: C ‾ r e s u l t = S r c − D s t \overline{C}_{result} = Src - Dst Cresult=SrcDst
  • GL_FUNC_REVERSE_SUBTRACT:将两个分量相减,但顺序相反: C ‾ r e s u l t = D s t − S r c \overline{C}_{result} = Dst - Src Cresult=DstSrc
  • GL_MIN:取两个分量中的最小值: C ‾ r e s u l t = m i n ( D s t , S r c ) \overline{C}_{result} = min(Dst, Src) Cresult=min(Dst,Src)
  • GL_MAX:取两个分量中的最大值: C ‾ r e s u l t = m a x ( D s t , S r c ) \overline{C}_{result} = max (Dst, Src) Cresult=max(Dst,Src)

通常,我们可以省略调用 glBlendEquation,因为 GL_FUNC_ADD 对于大多数操作来说都是我们期望的混合方程。但是,如果你想打破常规,其他方程可能更符合你的需求。

渲染半透明纹理

既然我们已经知道OpenGL是如何处理混合的了,是时候将我们的知识运用到实战中了,我们将会在场景中添加几个半透明的窗户。我们将使用本节开始的那个场景,但是这次不再是渲染草的纹理了,我们现在将使用本节开始时的那个透明的窗户纹理。

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

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

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

#version 330 core
out vec4 FragColor;

in vec2 TexCoords;

uniform sampler2D texture1;

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

现在(每当OpenGL渲染了一个片段时)它都会将当前片段的颜色和当前颜色缓冲中的片段颜色根据alpha值来进行混合。由于窗户纹理的玻璃部分是半透明的,我们应该能通窗户中看到背后的场景了。

image.png

如果你仔细看的话,你可能会注意到有些不对劲。最前面窗户的透明部分遮蔽了背后的窗户?这为什么会发生呢?

发生这一现象的原因是,深度测试和混合一起使用的话会产生一些麻烦。当写入深度缓冲时,深度缓冲不会检查片段是否是透明的,所以透明的部分会和其它值一样写入到深度缓冲中。结果就是窗户的整个四边形不论透明度都会进行深度测试。即使透明的部分应该显示背后的窗户,深度测试仍然丢弃了它们。

所以我们不能随意地决定如何渲染窗户,让深度缓冲解决所有的问题了。这也是混合变得有些麻烦的部分。要想保证窗户中能够显示它们背后的窗户,我们需要首先绘制背后的这部分窗户。这也就是说在绘制的时候,我们必须先手动将窗户按照最远到最近来排序,再按照顺序渲染。

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


我看的时候被这里搞蒙了,不理解为什么不透明物体(cube)与透明物体(玻璃)混合效果良好,但是透明物体之间却不能正常混合。这里要着重理解混合发生的 C ‾ s o u r c e \overline{C}_{source} Csource C ‾ d e s t i n a t i o n \overline{C}_{destination} Cdestination 对象是什么?

  • C ‾ s o u r c e \overline{C}_{source} Csource:当前正在绘制的片段颜色是什么
  • C ‾ d e s t i n a t i o n \overline{C}_{destination} Cdestination:颜色缓冲区已存在的颜色,即上一次绘制的片段颜色是什么

我们代码是这样的:

// cubes
glBindVertexArray(cubeVAO);
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, cubeTexture);
model = glm::translate(model, glm::vec3(-1.0f, 0.0f, -1.0f));
shader.setMat4("model", model);
glDrawArrays(GL_TRIANGLES, 0, 36);
model = glm::mat4(1.0f);
model = glm::translate(model, glm::vec3(2.0f, 0.0f, 0.0f));
shader.setMat4("model", model);
glDrawArrays(GL_TRIANGLES, 0, 36);
// floor
glBindVertexArray(planeVAO);
glBindTexture(GL_TEXTURE_2D, floorTexture);
shader.setMat4("model", glm::mat4(1.0f));
glDrawArrays(GL_TRIANGLES, 0, 6);
glBindVertexArray(0);
// vegetation
glBindVertexArray(vegetationVAO);
glBindTexture(GL_TEXTURE_2D, grassTexture);
for (unsigned int i = 0; i < vegetation.size(); i++) {
	model = glm::mat4(1.0f);
	model = glm::translate(model, vegetation[i]);
	shader.setMat4("model", model);
	glDrawArrays(GL_TRIANGLES, 0, 6);
}

先绘制了 cubes 与 floor 这两种不透明物体,然后再绘制玻璃这个透明物体。在绘制透明物体时, C ‾ s o u r c e \overline{C}_{source} Csource 就是当前绘制的颜色,即玻璃本身的颜色, C ‾ d e s t i n a t i o n \overline{C}_{destination} Cdestination 就是上一次绘制的颜色,即 cubes 或者 floor 的颜色。在这种情况下,玻璃就会反应出 cubes 与 floor 的颜色,因为混合过程中考虑了它们颜色的影响。上述过程中,始终满足不透明的 cubes 与 floor 在玻璃之后绘制,所以透过任意一个玻璃你都能够观察到不透明的 floor 或者 cubes。

但对于透明的玻璃来说,绘制是按照固定的顺序执行的,如果一个在你视角来说较远的玻璃恰好最先绘制,那么它的颜色就会被在它之后绘制的玻璃所混合(颜色已位于缓冲区内),那么透过较近的玻璃就能观察到较远的玻璃。反之,如果较近的玻璃先被绘制,此时缓冲区内的颜色就无法混合较远的玻璃颜色,那么就会出现最前面的玻璃遮挡后面玻璃的情况。

image.png

所以我们需要确定绘制的正确顺序来保证混合效果的正常进行!


不要打乱顺序

为了使混合在多个物体上正确工作,我们需要先绘制最远的物体,最后绘制最近的物体。不需要混合的物体仍然可以使用深度缓冲正常绘制,所以它们不需要排序。但我们仍然要保证它们在绘制(排序的)透明物体之前已经绘制完毕了。当绘制一个有不透明和透明物体的场景的时候,大体的原则如下:

  1. 先绘制所有不透明的物体。
  2. 对所有透明的物体排序。
  3. 按顺序绘制所有透明的物体。

排序透明物体的一种方法是,从观察者视角获取物体的距离。这可以通过计算摄像机位置向量和物体的位置向量之间的距离获得。接下来,我们将距离和它对应的位置向量存储到一个 STL 库的 map 数据结构中。map 会自动根据键值(Key)对它的值排序,所以只要我们添加了所有的位置,并以它的距离作为键,它们就会自动根据距离值排序了。

std::map<float, glm::vec3> sorted;
for (unsigned int i = 0; i < windows.size(); i++) {
    float distance = glm::length(camera.Position - windows[i]);
    sorted[distance] = windows[i];
}

结果就是一个排序后的容器对象,它根据 distance 键值从低到高储存了每个窗户的位置。

之后,在渲染时,我们将以逆序(从远到近)从 map 中获取值,然后以正确的顺序绘制对应的窗户:

for (std::map<float, glm::vec3>::reverse_iterator it = sorted.rbegin(); it != sorted.rend(); ++it) {
    model = glm::mat4();
    model = glm::translate(model, it->second);
    shader.setMat4("model", model);
    glDrawArrays(GL_TRIANGLES, 0, 6);
}

我们使用了 map 的一个反向迭代器(Reverse Iterator),反向遍历其中的条目,并将每个窗户四边形位移到对应的窗户位置上。这是一个比较简单的排序透明物体的实现,它可以修复之前的问题,现在场景看起来会更加真实自然。

image.png

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

在场景中排序物体是一个很困难的技术,很大程度上由你场景的类型所决定,更别说它额外需要消耗的处理能力了。完整渲染一个包含不透明和透明物体的场景并不是那么容易。更高级的技术还有次序无关透明度(Order Independent Transparency, OIT),但这超出本教程的范围了。现在,你还是必须要普通地混合你的物体,但如果你很小心,并且知道目前方法的限制的话,你仍然能够获得一个比较不错的混合实现。

本次项目源码:混合 - GitCode


面剔除


试想一下,你面前有一个 3D 立方体。无论你从哪个方向观察,最多能同时看到几个面?如果你空间想象力足够好,应该会发现最多只能看到三个面。无论你如何调整观察位置和方向,都无法同时看到立方体的三个以上面。那么,为什么我们要浪费时间去绘制那些我们根本看不见的面呢?如果能想办法丢弃这些看不见的面,我们就能节省超过 50% 的片段着色器执行时间!

之所以说是“超过 50%”,而不是“50%”,是因为从某些角度观察,我们甚至只能看到立方体的两个或一个面。在这种情况下,我们节省的计算量就远不止 50% 了。

这个想法很棒,但问题是如何判断一个物体的某个面是否对观察者可见呢?

想象一下任何一个封闭的 3D 形状。它的每个面都有两面:一面朝向观察者,一面背向观察者。如果我们只绘制朝向观察者的面,就能节省大量的计算资源。

这正是 面剔除(Face Culling) 技术所做的事情。OpenGL 可以检查所有朝向观察者的面(正面),并只渲染它们,而丢弃背向观察者的面(背面),从而大大减少片段着色器的调用次数(这可是非常耗费性能的!)。但是,我们仍然需要告诉 OpenGL 哪些面是正面,哪些面是背面。OpenGL 使用了一个巧妙的方法:分析顶点数据的环绕顺序(Winding Order)

环绕顺序

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

image.png

可以看到,我们首先定义了顶点1,之后我们可以选择定义顶点2或者顶点3,这个选择将定义了这个三角形的环绕顺序。下面的代码展示了这点:

float vertices[] = {
    // 顺时针
    vertices[0], // 顶点1
    vertices[1], // 顶点2
    vertices[2], // 顶点3
    // 逆时针
    vertices[0], // 顶点1
    vertices[2], // 顶点3
    vertices[1]  // 顶点2  
};

每组组成三角形图元的三个顶点都包含一个环绕顺序。OpenGL 在渲染图元时会使用此信息来判断三角形是正面三角形还是背面三角形。默认情况下,逆时针顶点定义的三角形将被视为正面三角形。

在定义顶点顺序时,你应该想象对应的三角形是面向你的,因此你定义的三角形从正面看去应该是逆时针的。这样定义顶点的一个优点是,实际的环绕顺序是在光栅化阶段(即顶点着色器运行之后)确定的。这些顶点是从观察者的角度看到的。

所有朝向观察者的三角形顶点都具有我们指定的正确环绕顺序,而立方体另一侧的三角形顶点则以相反的环绕顺序渲染。结果是,我们面向的三角形将是正面三角形,而背面的三角形则是背面三角形。如下图所示:

image.png

在顶点数据中,我们将两个三角形都以逆时针顺序定义(如果我们从正面观察这个三角形,正面三角形的顶点顺序是 1、2、3,背面三角形的顶点顺序也是 1、2、3)。然而,如果从观察者当前视角使用 1、2、3 的顺序来绘制,那么从观察者的方向来看,背面三角形将以顺时针顺序渲染。即使背面三角形是以逆时针定义的,但它现在却以顺时针顺序渲染。这正是我们想要剔除(Cull,丢弃)的不可见面!

面剔除

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

在之前教程中使用的立方体顶点数据并不是按照逆时针环绕顺序定义的,所以我更新了顶点数据,来反映逆时针的环绕顺序,你可以从这里复制它们。尝试想象这些顶点,确认在每个三角形中它们都是以逆时针定义的,这是一个很好的习惯。

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

glEnable(GL_CULL_FACE);

从这一句代码之后,所有背向面都将被丢弃(尝试飞进立方体内部,看看所有的内面是不是都被丢弃了)。目前我们在渲染片段的时候能够节省50%以上的性能,但注意这只对像立方体这样的封闭形状有效。当我们想要绘制上一节中的草时,我们必须要再次禁用面剔除,因为它们的正向面和背向面都应该是可见的。

开启之前(线框模式渲染 glPolygonMode(GL_FRONT_AND_BACK, GL_LINE);

image.png

开启之后:

image.png

OpenGL允许我们改变需要剔除的面的类型。如果我们只想剔除正向面而不是背向面会怎么样?我们可以调用glCullFace来定义这一行为:

glCullFace(GL_FRONT);

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

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

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

glFrontFace(GL_CCW);

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

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

glEnable(GL_CULL_FACE);
glCullFace(GL_BACK);
glFrontFace(GL_CW);

这样的结果是只有背向面被渲染了:

image.png

注意你可以仍使用默认的逆时针环绕顺序,但剔除正向面,来达到相同的效果:

glEnable(GL_CULL_FACE);
glCullFace(GL_FRONT);

可以看到,面剔除是一个提高OpenGL程序性能的很棒的工具。但你需要记住哪些物体能够从面剔除中获益,而哪些物体不应该被剔除。

练习

  • 你能够重新定义顶点数据,将每个三角形设置为顺时针顺序,并将顺时针的三角形设置为正向面,仍将场景渲染出来吗?:参考解答

本次项目源码:面剔除 - GitCode


帧缓冲


到目前为止,我们已经使用了许多屏幕缓冲区:

  • 颜色缓冲区: 用于存储每个像素的颜色信息。
  • 深度缓冲区: 用于存储每个像素的深度信息,用于实现遮挡效果。
  • 模板缓冲区: 允许我们根据一些条件丢弃特定片段。

这些缓冲区结合在一起称为帧缓冲区(Framebuffer),它存储在 GPU 内存中的某个位置。OpenGL 允许我们定义自己的帧缓冲区,这意味着我们可以自定义颜色缓冲区,甚至是深度缓冲区和模板缓冲区。

我们目前的所有操作都是在默认帧缓冲区的渲染缓冲区上进行的。默认帧缓冲区是在创建窗口时由 GLFW 自动生成和配置的。通过创建我们自己的帧缓冲区,我们可以获得额外的渲染目标。

你可能一时无法理解帧缓冲的实际应用,但将场景渲染到不同的帧缓冲区可以实现很多高级渲染效果,例如:

  • 镜面反射: 通过将场景渲染到一个帧缓冲区,然后将其作为纹理应用到另一个物体上,可以实现镜面反射效果。
  • 后期处理: 通过将场景渲染到一个帧缓冲区,然后对其进行处理,可以实现各种后期处理效果,如模糊、锐化、色彩校正等。

我们首先会讨论帧缓冲区是如何工作的,然后再来实现这些炫酷的后期处理效果。

创建一个帧缓冲

与 OpenGL 中的其他对象一样,我们使用 glGenFramebuffers 函数来创建一个帧缓冲对象(Framebuffer Object,FBO):

unsigned int fbo;
glGenFramebuffers(1, &fbo);

创建和使用对象的方式我们已经见过很多次了,所以它的使用函数也和其他对象类似。首先我们创建一个帧缓冲对象,将它绑定为激活的(Active)帧缓冲,做一些操作,之后解绑帧缓冲。我们使用 glBindFramebuffer 来绑定帧缓冲:

glBindFramebuffer(GL_FRAMEBUFFER, fbo);

绑定到 GL_FRAMEBUFFER 目标之后,所有读取和写入帧缓冲的操作都会影响当前绑定的帧缓冲。我们也可以使用 GL_READ_FRAMEBUFFERGL_DRAW_FRAMEBUFFER,将一个帧缓冲分别绑定到读取目标写入目标

  • 绑定到 GL_READ_FRAMEBUFFER 的帧缓冲将会在所有像 glReadPixels 的读取操作中使用。
  • 而绑定到 GL_DRAW_FRAMEBUFFER 的帧缓冲将会被用作渲染、清除等写入操作的目标。

大多数情况你都不需要区分它们,通常都会使用 GL_FRAMEBUFFER,绑定到两个目标。

不幸的是,我们现在还不能直接使用我们的帧缓冲,因为它还不 完整(Complete)。一个完整的帧缓冲需要满足以下条件:

  1. 附加至少一个缓冲: 必须附加至少一个缓冲(颜色、深度或模板缓冲)。
  2. 至少有一个颜色附件: 必须至少有一个颜色附件(Attachment)。
  3. 所有附件都必须是完整的: 所有附件都必须是完整的(保留了内存)。
  4. 每个缓冲都应该有相同的样本数: 每个缓冲都应该有相同的样本数(sample)。(如果你不知道什么是样本,不要担心,我们将在之后的教程中讲到)

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

if (glCheckFramebufferStatus(GL_FRAMEBUFFER) == GL_FRAMEBUFFER_COMPLETE) {
    // 执行胜利的舞蹈
}

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

glBindFramebuffer(GL_FRAMEBUFFER, 0);

在完成所有的帧缓冲操作之后,不要忘记删除这个帧缓冲对象:

glDeleteFramebuffers(1, &fbo);

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

纹理附件

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

创建纹理

为帧缓冲区创建一个纹理和创建一个普通的纹理差不多:

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

主要的区别是,我们将维度设置为了屏幕大小(尽管这不是必须的),并且我们给纹理的 data 参数传递了 NULL。对于这个纹理,我们仅仅分配了内存而没有填充它。填充这个纹理将会在我们渲染到帧缓冲区之后进行。同样注意我们并不关心环绕方式或多级渐远纹理,我们在大多数情况下都不会需要它们。

如果你想将你的屏幕渲染到一个更小或更大的纹理上,你需要(在渲染到帧缓冲区之前)再次调用 glViewport,使用纹理的新维度作为参数,否则只有一小部分的纹理或屏幕会被渲染到这个纹理上。

附加到帧缓冲区

现在我们已经创建好一个纹理了,要做的最后一件事就是将它附加到帧缓冲区上:

glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, texture, 0);

glFrameBufferTexture2D 有以下参数:

  • target: 帧缓冲的目标(绘制、读取或者两者皆有)。
  • attachment: 我们想要附加的附件类型。当前我们正在附加一个颜色附件。注意最后的 0 意味着我们可以附加多个颜色附件。我们将在之后的教程中提到。
  • textarget: 你希望附加的纹理类型。
  • texture: 要附加的纹理本身。
  • level: 多级渐远纹理的级别。我们将它保留为 0
深度和模板附件

除了颜色附件之外,我们还可以附加一个深度和模板缓冲纹理到帧缓冲对象中。要附加深度缓冲的话,我们将附件类型设置为 GL_DEPTH_ATTACHMENT。注意纹理的格式(Format)和内部格式(Internalformat)类型将变为 GL_DEPTH_COMPONENT,来反映深度缓冲的储存格式。要附加模板缓冲的话,你要将第二个参数设置为 GL_STENCIL_ATTACHMENT,并将纹理的格式设定为 GL_STENCIL_INDEX

// 深度缓冲
glTexImage2D(GL_TEXTURE_2D, 0, GL_DEPTH_COMPONENT, width, height, 0, GL_DEPTH_COMPONENT, GL_FLOAT, nullptr);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_TEXTURE_2D, depthTexture, 0);
// 模板缓冲
glTexImage2D(GL_TEXTURE_2D, 0, GL_STENCIL_INDEX, width, height, 0, GL_STENCIL_INDEX, GL_UNSIGNED_BYTE, nullptr);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_STENCIL_ATTACHMENT, GL_TEXTURE_2D, stencilTexture, 0);

也可以将深度缓冲和模板缓冲附加为一个单独的纹理。纹理的每 32 位数值将包含 24 位的深度信息和 8 位的模板信息。要将深度和模板缓冲附加为一个纹理的话,我们使用 GL_DEPTH_STENCIL_ATTACHMENT 类型,并配置纹理的格式,让它包含合并的深度和模板值。将一个深度和模板缓冲附加为一个纹理到帧缓冲的例子可以在下面找到:

glTexImage2D(
    GL_TEXTURE_2D, 0, GL_DEPTH24_STENCIL8, 800, 600, 0,
    GL_DEPTH_STENCIL, GL_UNSIGNED_INT_24_8, NULL
);

glFramebufferTexture2D(GL_FRAMEBUFFER, GL_DEPTH_STENCIL_ATTACHMENT, GL_TEXTURE_2D, texture, 0);

渲染缓冲对象附件

渲染缓冲对象(Renderbuffer Object) 是后来引入 OpenGL 的,作为一种可用的帧缓冲附件类型。在过去,纹理是唯一的选择。与纹理图像一样,渲染缓冲对象是一个真正的缓冲,即一系列的字节、整数、像素等。渲染缓冲对象附加的优点是,它将数据存储为 OpenGL 原生的渲染格式,它是为离屏渲染到帧缓冲而优化的。

特点
  • 存储格式: 渲染缓冲对象直接将所有渲染数据存储到它的缓冲中,不会做任何针对纹理格式的转换,使其成为一个更快的可写存储介质。
  • 访问权限: 渲染缓冲对象通常是只写的,所以你不能直接读取它们(比如使用纹理访问)。当然,你仍然可以使用 glReadPixels 来读取它,这会从当前绑定的帧缓冲(而不是附件本身)中返回特定区域的像素。
  • 性能: 由于其数据已经是原生格式,当写入或复制它的数据到其他缓冲中时非常快。因此,交换缓冲这样的操作在使用渲染缓冲对象时会非常快。

我们在每个渲染迭代最后使用的 glfwSwapBuffers,也可以通过渲染缓冲对象实现:只需要写入一个渲染缓冲图像,并在最后交换到另外一个渲染缓冲就可以了。渲染缓冲对象对这种操作非常完美。

创建和绑定

创建渲染缓冲对象的代码和帧缓冲的代码很类似:

unsigned int rbo;
glGenRenderbuffers(1, &rbo);

类似地,我们需要绑定这个渲染缓冲对象,让之后所有的渲染缓冲操作影响当前的 rbo

glBindRenderbuffer(GL_RENDERBUFFER, rbo);
深度和模板附件

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

创建一个深度和模板渲染缓冲对象可以通过调用 glRenderbufferStorage 函数来完成:

glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH24_STENCIL8, 800, 600);

创建一个渲染缓冲对象和纹理对象类似,不同的是这个对象是专门被设计作为帧缓冲附件使用的,而不是纹理那样的通用数据缓冲(General Purpose Data Buffer)。这里我们选择 GL_DEPTH24_STENCIL8 作为内部格式,它封装了 24 位的深度和 8 位的模板缓冲。

附加到帧缓冲区

最后一件事就是附加这个渲染缓冲对象:

glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_STENCIL_ATTACHMENT, GL_RENDERBUFFER, rbo);
何时使用渲染缓冲对象

渲染缓冲对象能为你的帧缓冲对象提供一些优化,但知道什么时候使用渲染缓冲对象,什么时候使用纹理是很重要的。通常的规则是,如果你不需要从一个缓冲中采样数据,那么对这个缓冲使用渲染缓冲对象会是明智的选择。如果你需要从缓冲中采样颜色或深度值等数据,那么你应该选择纹理附件。性能方面它不会产生非常大的影响的。

渲染到纹理

既然我们已经知道帧缓冲(大概)是怎么工作的了,是时候实践它们了。我们将会将场景渲染到一个附加到帧缓冲对象上的颜色纹理中,之后将在一个横跨整个屏幕的四边形上绘制这个纹理。这样视觉输出和没使用帧缓冲时是完全一样的,但这次是打印到了一个四边形上。这为什么很有用呢?我们会在下一部分中知道原因。

首先要创建一个帧缓冲对象,并绑定它,这些都很直观:

unsigned int framebuffer;
glGenFramebuffers(1, &framebuffer);
glBindFramebuffer(GL_FRAMEBUFFER, framebuffer);

接下来我们需要创建一个纹理图像,我们将它作为一个颜色附件附加到帧缓冲上。我们将纹理的维度设置为窗口的宽度和高度,并且不初始化它的数据:

// 生成纹理
unsigned int texColorBuffer;
glGenTextures(1, &texColorBuffer);
glBindTexture(GL_TEXTURE_2D, texColorBuffer);
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);
glBindTexture(GL_TEXTURE_2D, 0);

// 将它附加到当前绑定的帧缓冲对象
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, texColorBuffer, 0); 

我们还希望 OpenGL 能够进行深度测试(如果你需要的话,还有模板测试),所以我们需要添加一个深度(和模板)附件到帧缓冲中。由于我们只希望采样颜色缓冲,而不是其他的缓冲,我们可以为它们创建一个渲染缓冲对象。还记得当我们不需要采样缓冲的时候,渲染缓冲对象是更好的选择吗?

创建一个渲染缓冲对象并不是非常复杂。我们需要记住的唯一事情是,我们将它创建为一个深度和模板附件渲染缓冲对象。我们将它的内部格式设置为 GL_DEPTH24_STENCIL8,对我们来说这个精度已经足够了。

unsigned int rbo;
glGenRenderbuffers(1, &rbo);
glBindRenderbuffer(GL_RENDERBUFFER, rbo); 
glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH24_STENCIL8, 800, 600);
// 当我们为渲染缓冲对象分配了足够的内存之后,我们可以解绑这个渲染缓冲。
glBindRenderbuffer(GL_RENDERBUFFER, 0);

接下来,作为完成帧缓冲之前的最后一步,我们将渲染缓冲对象附加到帧缓冲的深度和模板附件上:

glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_STENCIL_ATTACHMENT, GL_RENDERBUFFER, rbo);

最后,我们希望检查帧缓冲是否是完整的,如果不是,我们将打印错误信息。

if (glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE)
    std::cout << "ERROR::FRAMEBUFFER:: Framebuffer is not complete!" << std::endl;
//记得要解绑帧缓冲,保证我们不会不小心渲染到错误的帧缓冲上。
glBindFramebuffer(GL_FRAMEBUFFER, 0);

现在帧缓冲已经完整了,我们只需绑定这个帧缓冲对象,让渲染结果输出到帧缓冲的附件中,而不是默认帧缓冲。后续的渲染指令都将影响当前绑定的帧缓冲。所有深度和模板操作都将从当前绑定帧缓冲的深度和模板附件中读取(如果存在的话)。如果你忽略了深度缓冲,那么所有深度测试操作都将失效,因为当前绑定的帧缓冲中没有深度缓冲。

所以,要想将场景绘制到纹理上,我们需要采取以下步骤:

  1. 绑定自定义帧缓冲: 将我们创建的帧缓冲绑定为当前激活的帧缓冲,并像往常一样渲染场景。此时,渲染结果将写入到该帧缓冲的附件(即纹理)中。
  2. 绑定默认帧缓冲: 重新绑定默认帧缓冲,以便后续的渲染操作会影响到屏幕输出。
  3. 绘制后处理四边形: 绘制一个覆盖整个屏幕的四边形,并将之前渲染到帧缓冲的纹理作为它的纹理。这样,我们就可以在屏幕上显示渲染到纹理上的内容,并进行各种后期处理。

我们将会绘制深度测试小节中的场景,但这次使用的是旧的箱子纹理。(笔主使用的不是,沿用的上一节的例子)

为了绘制这个四边形,我们将创建一套简单的着色器。由于我们提供的是标准化设备坐标的顶点坐标,因此无需进行复杂的矩阵变换,可以直接将它们设置为顶点着色器的输出。

float quadVertices[] = { // vertex attributes for a quad that fills the entire screen in Normalized Device Coordinates.
	// positions   // texCoords
	-1.0f,  1.0f,  0.0f, 1.0f,
	-1.0f, -1.0f,  0.0f, 0.0f,
	 1.0f, -1.0f,  1.0f, 0.0f,

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

顶点着色器:

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

out vec2 TexCoords;

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

片段着色器则更加基础,我们唯一要做的就是从纹理中采样:

#version 330 core
out vec4 FragColor;

in vec2 TexCoords;

uniform sampler2D screenTexture;

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

接下来,你需要为屏幕四边形创建并配置一个 VAO。

unsigned int quadVAO, quadVBO;
glGenVertexArrays(1, &quadVAO);
glGenBuffers(1, &quadVBO);
glBindVertexArray(quadVAO);
glBindBuffer(GL_ARRAY_BUFFER, quadVBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(quadVertices), &quadVertices, GL_STATIC_DRAW);
glEnableVertexAttribArray(0);
glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 4 * sizeof(float), (void*)0);
glEnableVertexAttribArray(1);
glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, 4 * sizeof(float), (void*)(2 * sizeof(float)));

帧缓冲的一个渲染迭代将具有以下结构:

// 第一处理阶段(Pass)
glBindFramebuffer(GL_FRAMEBUFFER, framebuffer);
glClearColor(0.1f, 0.1f, 0.1f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); // 我们现在不使用模板缓冲
glEnable(GL_DEPTH_TEST);
DrawScene();

// 第二处理阶段
glBindFramebuffer(GL_FRAMEBUFFER, 0); // 返回默认帧缓冲
glClearColor(1.0f, 1.0f, 1.0f, 1.0f); 
glClear(GL_COLOR_BUFFER_BIT);

screenShader.use();  
glBindVertexArray(quadVAO);
glDisable(GL_DEPTH_TEST);
glBindTexture(GL_TEXTURE_2D, textureColorbuffer);
glDrawArrays(GL_TRIANGLES, 0, 6);

注意事项

  • 清除缓冲: 由于我们使用的每个帧缓冲都有其自己的一套缓冲,因此我们需要设置适当的位,并调用 glClear 来清除这些缓冲。
  • 深度测试: 绘制四边形时,我们将禁用深度测试,因为我们绘制的是一个简单的四边形,无需考虑深度。在绘制普通场景时,我们将重新启用深度测试。

有很多步骤都可能会出错,所以如果你没有得到输出的话,尝试调试程序,并重新阅读本节的相关部分。如果所有的东西都能够正常工作,你将会得到下面这样的视觉输出:

image.png

左边展示的是视觉输出,它和深度测试中是完全一样的,但这次是渲染在一个简单的四边形上。如果我们使用线框模式渲染场景,就会变得很明显,我们在默认的帧缓冲中只绘制了一个简单的四边形。

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

所以这个有什么用处呢?因为我们能够以一个纹理图像的方式访问已渲染场景中的每个像素,我们可以在片段着色器中创建出非常有趣的效果。这些有趣效果统称为 后期处理(Post-processing) 效果。

后期处理

既然整个场景都被渲染到了一个纹理上,我们可以简单地通过修改纹理数据创建出一些非常有意思的效果。在这一部分中,我们将会向你展示一些流行的后期处理效果,并告诉你改如何使用创造力创建你自己的效果。

让我们先从最简单的后期处理效果开始。

反相

我们现在能够访问渲染输出的每个颜色,所以在(译注:屏幕的)片段着色器中返回这些颜色的反相(Inversion)并不是很难。我们将会从屏幕纹理中取颜色值,然后用1.0减去它,对它进行反相:

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

尽管反相是一个相对简单的后期处理效果,它已经能创造一些奇怪的效果了:

image.png

在片段着色器中仅仅使用一行代码,就能让整个场景的颜色都反相了。很酷吧?

灰度

另外一个很有趣的效果是,移除场景中除了黑白灰以外所有的颜色,让整个图像灰度化(Grayscale)。很简单的实现方式是,取所有的颜色分量,将它们平均化:

void main()
{
    FragColor = texture(screenTexture, TexCoords);
    float average = (FragColor.r + FragColor.g + FragColor.b) / 3.0;
    FragColor = vec4(average, average, average, 1.0);
}

这已经能创造很好的结果了,但人眼会对绿色更加敏感一些,而对蓝色不那么敏感,所以为了获取物理上更精确的效果,我们需要使用加权的(Weighted)通道:

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

image.png

你可能不会立刻发现有什么差别,但在更复杂的场景中,这样的加权灰度效果会更真实一点。

核效果

在一个纹理图像上做后期处理的另外一个好处是,我们可以从纹理的其它地方采样颜色值。比如说我们可以在当前纹理坐标的周围取一小块区域,对当前纹理值周围的多个纹理值进行采样。我们可以结合它们创建出很有意思的效果。

核(Kernel)(或卷积矩阵(Convolution Matrix))是一个类矩阵的数值数组,它的中心为当前的像素,它会用它的核值乘以周围的像素值,并将结果相加变成一个值。所以,基本上我们是在对当前像素周围的纹理坐标添加一个小的偏移量,并根据核将结果合并。下面是核的一个例子:

( 2 2 2 2 − 15 2 2 2 2 ) \begin{pmatrix} 2 & 2 & 2\\ 2 & -15 & 2\\ 2 & 2 & 2 \end{pmatrix} 2222152222

这个核取了8个周围像素值,将它们乘以2,而把当前的像素乘以-15。这个核的例子将周围的像素乘上了一个权重,并将当前像素乘以一个比较大的负权重来平衡结果。

你在网上找到的大部分核将所有的权重加起来之后都应该会等于1,如果它们加起来不等于1,这就意味着最终的纹理颜色将会比原纹理值更亮或者更暗了。

核是后期处理一个非常有用的工具,它们使用和实验起来都很简单,网上也能找到很多例子。我们需要稍微修改一下片段着色器,让它能够支持核。我们假设使用的核都是3x3核(实际上大部分核都是):

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

在片段着色器中,我们首先为周围的纹理坐标创建了一个9个 vec2 偏移量的数组。偏移量是一个常量,你可以按照你的喜好自定义它。之后我们定义一个核,在这个例子中是一个 锐化(Sharpen) 核,它会采样周围的所有像素,锐化每个颜色值。最后,在采样时我们将每个偏移量加到当前纹理坐标上,获取需要采样的纹理,之后将这些纹理值乘以加权的核值,并将它们加到一起。

这个锐化核看起来是这样的:

image.png

模糊

创建 模糊(Blur) 效果的核是这样的:

( 1 2 1 2 4 2 1 2 1 ) / 16 \begin{pmatrix} 1 & 2 & 1\\ 2 & 4 & 2\\ 1 & 2 & 1 \end{pmatrix} / 16 121242121 /16

由于所有值的和是16,所以直接返回合并的采样颜色将产生非常亮的颜色,所以我们需要将核的每个值都除以16。最终的核数组将会是:

float kernel[9] = float[](
    1.0 / 16, 2.0 / 16, 1.0 / 16,
    2.0 / 16, 4.0 / 16, 2.0 / 16,
    1.0 / 16, 2.0 / 16, 1.0 / 16  
);

通过在片段着色器中改变核的float数组,我们完全改变了后期处理效果。它现在看起来是这样子的:

image.png

这样的模糊效果创造了很多的可能性。我们可以随着时间修改模糊的量,创造出玩家醉酒时的效果,或者在主角没带眼镜的时候增加模糊。模糊也能够让我们来平滑颜色值,我们将在之后教程中使用到。

你可以看到,只要我们有了这个核的实现,创建炫酷的后期处理特效是非常容易的事。我们再来看最后一个很流行的效果来结束本节的讨论

边缘检测

下面的 边缘检测(Edge-detection) 核和锐化核非常相似:

( 1 1 1 1 − 8 1 1 1 1 ) \begin{pmatrix} 1 & 1 & 1\\ 1 & -8 & 1\\ 1 & 1 & 1 \end{pmatrix} 111181111

这个核高亮了所有的边缘,而暗化了其它部分,在我们只关心图像的边角的时候是非常有用的。

image.png

你可能不会奇怪,像是Photoshop这样的图像修改工具/滤镜使用的也是这样的核。因为显卡处理片段的时候有着极强的并行处理能力,我们可以很轻松地在实时的情况下逐像素对图像进行处理。所以图像编辑工具在图像处理的时候会更倾向于使用显卡。

注意,核在对屏幕纹理的边缘进行采样的时候,由于还会对中心像素周围的8个像素进行采样,其实会取到纹理之外的像素。由于环绕方式默认是GL_REPEAT,所以在没有设置的情况下取到的是屏幕另一边的像素,而另一边的像素本不应该对中心像素产生影响,这就可能会在屏幕边缘产生很奇怪的条纹。为了消除这一问题,我们可以将屏幕纹理的环绕方式都设置为 GL_CLAMP_TO_EDGE。这样子在取到纹理外的像素时,就能够重复边缘的像素来更精确地估计最终的值了。

练习

First

问题描述:你能使用 framebuffers 创建一个后视镜吗? 为此,你必须绘制两次场景: 一次将相机旋转 180 度,另一次将相机正常旋转。试着在屏幕顶部创建一个小的四边形来应用镜像纹理,就像这样;解决方案

思路:将 camera 旋转 180°,渲染场景到纹理中。然后将 camera 恢复原状,正常渲染场景,在屏幕上方绘制一个长方体采集纹理的内容。

image.png

源码:帧缓存 - GitCode

Second

问题描述:摆弄内核值并创建您自己感兴趣的后处理效果。尝试在互联网上搜索其他有趣的内核。


立方体贴图


我们已经使用 2D 纹理很长时间了,但除此之外,还有更多的纹理类型等待我们探索。在本节中,我们将讨论一种将多个纹理组合起来映射到一张纹理上的纹理类型:立方体贴图(Cube Map)

简单来说,立方体贴图就是一个包含了 6 个 2D 纹理的纹理,每个 2D 纹理都组成了立方体的一个面:一个有纹理的立方体。你可能会奇怪,这样一个立方体有什么用途呢?为什么要把 6 张纹理合并到一张纹理中,而不是直接使用 6 个单独的纹理呢?立方体贴图有一个非常有用的特性,它可以通过一个方向向量来进行索引/采样。

假设我们有一个 1x1x1 的单位立方体,方向向量的原点位于它的中心。使用一个橘黄色的方向向量来从立方体贴图上采样一个纹理值会像这样:

image.png

方向向量的大小并不重要,只要提供了方向,OpenGL 就会获取方向向量(最终)所击中的纹素,并返回对应的采样纹理值。

如果我们假设将这样的立方体贴图应用到一个立方体上,采样立方体贴图所使用的方向向量将和立方体(插值的)顶点位置非常相像。这样子,只要立方体的中心位于原点,我们就能使用立方体的实际位置向量来对立方体贴图进行采样了。接下来,我们可以将所有顶点的纹理坐标当做是立方体的顶点位置。最终得到的结果就是可以访问立方体贴图上正确 面(Face) 纹理的一个纹理坐标。

创建立方体贴图

立方体贴图和其他纹理一样,要创建一个立方体贴图,我们需要生成一个纹理,并将其绑定到纹理目标上,然后再进行其他纹理操作。这次要绑定到 GL_TEXTURE_CUBE_MAP

unsigned int textureID;
glGenTextures(1, &textureID);
glBindTexture(GL_TEXTURE_CUBE_MAP, textureID);

由于立方体贴图包含 6 个纹理(每个面一个),我们需要调用 glTexImage2D 函数 6 次,参数和之前教程中类似。但这一次,我们将纹理目标(target)参数设置为立方体贴图的一个特定面,告诉 OpenGL 我们在对立方体贴图的哪一个面创建纹理。这意味着我们需要对立方体贴图的每个面都调用一次 glTexImage2D

由于我们有 6 个面,OpenGL 提供了 6 个特殊的纹理目标,专门对应立方体贴图的一个面。

纹理目标方位
GL_TEXTURE_CUBE_MAP_POSITIVE_X
GL_TEXTURE_CUBE_MAP_NEGATIVE_X
GL_TEXTURE_CUBE_MAP_POSITIVE_Y
GL_TEXTURE_CUBE_MAP_NEGATIVE_Y
GL_TEXTURE_CUBE_MAP_POSITIVE_Z
GL_TEXTURE_CUBE_MAP_NEGATIVE_Z

和 OpenGL 的很多枚举(Enum)一样,它们背后的 int 值是线性递增的,所以如果我们有一个纹理位置的数组或者 vector,我们就可以从 GL_TEXTURE_CUBE_MAP_POSITIVE_X 开始遍历它们,在每个迭代中对枚举值加 1,遍历整个纹理目标:

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

这里我们有一个叫做 textures_faces 的 vector,它包含了立方体贴图所需的所有纹理路径,并以表中的顺序排列。这将为当前绑定的立方体贴图中的每个面生成一个纹理。

因为立方体贴图和其他纹理没什么不同,我们也需要设定它的环绕和过滤方式:

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

不要被 GL_TEXTURE_WRAP_R 吓到,它仅仅是为纹理的 R 坐标设置了环绕方式,它对应的是纹理的第三个维度(和位置的 z 一样)。我们将环绕方式设置为 GL_CLAMP_TO_EDGE,这是因为正好处于两个面之间的纹理坐标可能无法击中一个面(由于一些硬件限制),所以通过使用 GL_CLAMP_TO_EDGE,OpenGL 将在我们对两个面之间采样的时候,永远返回它们的边界值。

在绘制使用立方体贴图的物体之前,我们要先激活对应的纹理单元,并绑定立方体贴图,这和普通的 2D 纹理没什么区别。

glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_CUBE_MAP, cubemapTexture);

在片段着色器中,我们使用了一个不同类型的采样器,samplerCube,我们将使用 texture 函数使用它进行采样,但这次我们将使用一个 vec3 的方向向量而不是 vec2。使用立方体贴图的片段着色器会像这样:

in vec3 textureDir; // 代表 3D 纹理坐标的方向向量
uniform samplerCube cubemap; // 立方体贴图的纹理采样器

void main() {
    FragColor = texture(cubemap, textureDir);
}

看起来很棒,但为什么要用它呢?恰好有一些很有意思的技术,使用立方体贴图来实现会简单得多。其中一个技术就是创建一个 天空盒(Skybox)

天空盒

天空盒是一个包含了整个场景的(大)立方体,它包含周围环境的6个图像,让玩家以为他处在一个比实际大得多的环境当中。游戏中使用天空盒的例子有群山、白云或星空。下面这张截图中展示的是星空的天空盒,它来自于『上古卷轴3』:

image.png

你可能现在已经猜到了,立方体贴图能完美满足天空盒的需求:我们有一个6面的立方体,每个面都需要一个纹理。在上面的图片中,他们使用了夜空的几张图片,让玩家产生其位于广袤宇宙中的错觉,但实际上他只是在一个小小的盒子当中。

你可以在网上找到很多像这样的天空盒资源。比如说这个网站就提供了很多天空盒。天空盒图像通常有以下的形式:

image.png

如果你将这六个面折成一个立方体,你就会得到一个完全贴图的立方体,模拟一个巨大的场景。一些资源可能会提供了这样格式的天空盒,你必须手动提取六个面的图像,但在大部分情况下它们都是6张单独的纹理图像。

之后我们将在场景中使用这个(高质量的)天空盒,它可以在这里下载到。

加载天空盒

由于天空盒本身就是一个立方体贴图,因此加载天空盒与之前加载立方体贴图并没有什么不同。为了加载天空盒,我们将使用下面的函数,它接受一个包含 6 个纹理路径的 vector:

unsigned int loadCubemap(std::vector<std::string> faces) {
    unsigned int textureID;
    glGenTextures(1, &textureID);
    glBindTexture(GL_TEXTURE_CUBE_MAP, textureID);

    int width, height, nrChannels;
    for (unsigned int i = 0; i < faces.size(); i++) {
        unsigned char *data = stbi_load(faces[i].c_str(), &width, &height, &nrChannels, 0);
        if (data) {
            glTexImage2D(GL_TEXTURE_CUBE_MAP_POSITIVE_X + i, 
                         0, GL_RGB, width, height, 0, GL_RGB, GL_UNSIGNED_BYTE, data
            );
            stbi_image_free(data);
        } else {
            std::cout << "Cubemap texture failed to load at path: " << faces[i] << std::endl;
            stbi_image_free(data);
        }
    }

    glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
    glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MAG_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);

    return textureID;
}

函数本身应该很熟悉了。它基本就是上一部分中立方体贴图的代码,只不过合并到了一个便于管理的函数中。

之后,在调用这个函数之前,我们需要将合适的纹理路径按照立方体贴图枚举指定的顺序加载到一个 vector 中。

std::vector<std::string> faces {
    "right.jpg",
    "left.jpg",
    "top.jpg",
    "bottom.jpg",
    "front.jpg",
    "back.jpg"
};
unsigned int cubemapTexture = loadCubemap(faces);

现在我们就将这个天空盒加载为一个立方体贴图了,它的 id 是 cubemapTexture。我们可以将它绑定到一个立方体中,替换掉用了很长时间的难看的纯色背景。

显示天空盒

由于天空盒是绘制在一个立方体上的,和其它物体一样,我们需要另一个 VAO、VBO 以及新的一组顶点。你可以在这里找到它的顶点数据。

用于贴图 3D 立方体的立方体贴图可以使用立方体的位置作为纹理坐标来采样。当立方体处于原点 (0, 0, 0) 时,它的每一个位置向量都是从原点出发的方向向量。这个方向向量正是获取立方体上特定位置的纹理值所需要的。正是因为这个,我们只需要提供位置向量而不用纹理坐标了。

要渲染天空盒的话,我们需要一组新的着色器,它们都不是很复杂。因为我们只有一个顶点属性,顶点着色器非常简单:

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

out vec3 TexCoords;

uniform mat4 projection;
uniform mat4 view;

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

注意,顶点着色器中很有意思的部分是,我们将输入的位置向量作为输出给片段着色器的纹理坐标。

片段着色器会将它作为输入来采样 samplerCube

#version 330 core
out vec4 FragColor;

in vec3 TexCoords;

uniform samplerCube skybox;

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

片段着色器非常直观。我们将顶点属性的位置向量作为纹理的方向向量,并使用它从立方体贴图中采样纹理值。

有了立方体贴图纹理,渲染天空盒现在就非常简单了,我们只需要绑定立方体贴图纹理,skybox 采样器就会自动填充上天空盒立方体贴图了。绘制天空盒时,我们需要将它变为场景中的第一个渲染的物体,并且禁用深度写入。这样子天空盒就会永远被绘制在其它物体的背后了。

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

如果你运行一下的话,你会发现出现了一些问题。我们希望天空盒是以玩家为中心的,这样不论玩家移动了多远,天空盒都不会变近,让玩家产生周围环境非常大的印象。然而,当前的观察矩阵会旋转、缩放和位移来变换天空盒的所有位置,所以当玩家移动的时候,立方体贴图也会移动!我们希望移除观察矩阵中的位移部分,让移动不会影响天空盒的位置向量。

image.png

你可能还记得在基础光照小节中,我们通过取 4x4 矩阵左上角的 3x3 矩阵来移除变换矩阵的位移部分。我们可以将观察矩阵转换为 3x3 矩阵(移除位移),再将其转换回 4x4 矩阵,来达到类似的效果。

glm::mat4 view = glm::mat4(glm::mat3(camera.GetViewMatrix()));

这将移除任何的位移,但保留旋转变换,让玩家仍然能够环顾场景。

有了天空盒,最终的效果就是一个看起来巨大的场景了。如果你在箱子周围转一转,你就能立刻感受到距离感,极大地提升了场景的真实度。最终的结果看起来是这样的:

image.png

优化

目前我们是首先渲染天空盒,然后再渲染场景中的其他物体。这样做可以工作,但效率并不高。如果我们先渲染天空盒,即便只有一小部分天空盒最终是可见的,也会对屏幕上的每个像素都运行一遍片段着色器。我们可以利用提前深度测试(Early Depth Testing)的特性,轻松丢弃那些不可见的片段来节省我们很多宝贵的带宽。

因此,我们将最后渲染天空盒,以获得性能提升。这样的话,深度缓冲就会填充上所有物体的深度值了,我们只需要在提前深度测试通过的地方渲染天空盒的片段就可以了,很大程度上减少了片段着色器的调用。问题是,天空盒很可能会渲染在所有其他对象之上,因为它只是一个 1x1x1 的立方体(意味着距离摄像机的距离也只有 1),会通过大部分的深度测试。不用深度测试来进行渲染不是解决方案,因为天空盒将会覆盖场景中的其他物体。我们需要欺骗深度缓冲,让它认为天空盒有着最大的深度值 1.0(深度缓冲的初始值为1.0f),只要它前面有一个物体,深度测试就会失败。

坐标系统小节中我们说过,透视除法是在顶点着色器运行之后执行的,将 gl_Position 的 xyz 坐标除以 w 分量。我们又从深度测试小节中知道,相除结果的 z 分量等于顶点的深度值。使用这些信息,我们可以将输出位置的 z 分量等于它的 w 分量,让 z 分量永远等于 1.0,这样的话,当透视除法执行之后,z 分量会变为 w / w = 1.0。

void main() {
    TexCoords = aPos;
    vec4 pos = projection * view * vec4(aPos, 1.0);
    gl_Position = pos.xyww;
}

最终的标准化设备坐标将永远会有一个等于 1.0 的 z 值:最大的深度值。结果就是天空盒只会在没有可见物体的地方渲染了(只有这样才能通过深度测试,其他所有的东西都在天空盒前面)。

我们还要改变一下深度函数,将它从默认的 GL_LESS 改为 GL_LEQUAL。深度缓冲将会填充上天空盒的 1.0 值,所以我们需要保证天空盒在值小于或等于深度缓冲而不是小于时通过深度测试。

你可以在这里找到优化后的源代码。

环境映射

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

反射

反射这种属性表现为物体(或物体的一部分)反射其周围环境,即物体的颜色或多或少等于其环境,这取决于观察者的视角。镜子就是一个典型的反射物体:它会根据观察者的视角反射其周围的环境。

反射的原理并不难。下图展示了我们如何计算反射向量,以及如何使用这个向量从立方体贴图中采样:

image.png

我们根据观察方向向量 I ‾ \overline{I} I 和物体的法向量 N ‾ \overline{N} N,来计算反射向量 R ‾ \overline{R} R。我们可以使用 GLSL 内置的 reflect 函数来计算这个反射向量。最终的 R ‾ \overline{R} R 向量将作为索引/采样立方体贴图的方向向量,返回环境的颜色值。最终的结果是物体看起来反射了天空盒。

由于我们已经在场景中配置好了天空盒,创建反射效果并不难。我们只需修改箱子的片段着色器,使其具有反射属性:

#version 330 core
out vec4 FragColor;

in vec3 Normal;
in vec3 Position;

uniform vec3 cameraPos;
uniform samplerCube skybox;

void main() {             
    vec3 I = normalize(Position - cameraPos);
    vec3 R = reflect(I, normalize(Normal));
    FragColor = vec4(texture(skybox, R).rgb, 1.0);
}

我们首先计算了观察/摄像机方向向量 I,并使用它来计算反射向量 R。之后,我们将使用 R 从天空盒立方体贴图中采样。注意,我们现在又有了一个片段的插值 NormalPosition 变量,所以我们需要更新一下顶点着色器。

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

out vec3 Normal;
out vec3 Position;

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

void main() {
    Normal = mat3(transpose(inverse(model))) * aNormal;
    Position = vec3(model * vec4(aPos, 1.0));
    gl_Position = projection * view * model * vec4(aPos, 1.0);
}

我们现在使用了一个法向量,所以我们将再次使用 法线矩阵(Normal Matrix) 来变换它们。Position 输出向量是一个世界空间的位置向量。顶点着色器的这个 Position 输出将用来在片段着色器内计算观察方向向量。

因为我们使用了法线,你还需要更新一下顶点数据,并更新属性指针。还要记得去设置cameraPos这个uniform。

接下来,我们在渲染箱子之前先绑定立方体贴图纹理:

glBindVertexArray(cubeVAO);
glBindTexture(GL_TEXTURE_CUBE_MAP, skyboxTexture);          
glDrawArrays(GL_TRIANGLES, 0, 36);

编译并运行代码,你将会得到一个像是镜子一样的箱子。周围的天空盒被完美地反射在箱子上。

image.png

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

当反射应用到一整个物体上(像是箱子)时,这个物体看起来就像是钢或者铬这样的高反射性材质。如果我们加载模型加载小节中的背包模型,我们会得到一种整个套装都是使用铬做成的效果:

image.png

这看起来非常棒,但在现实中大部分的模型都不具有完全反射性。我们可以引入反射贴图(Reflection Map),来给模型更多的细节。与漫反射和镜面光贴图一样,反射贴图也是可以采样的纹理图像,它决定这片段的反射性。通过使用反射贴图,我们可以知道模型的哪些部分该以什么强度显示反射。在本节的练习中,将由你来为我们之前创建的模型加载器中引入反射贴图,显著提升背包模型的细节

折射

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

折射是通过斯涅尔定律 (Snell’s Law)来描述的,使用环境贴图的话看起来像是这样:

image.png

同样,我们有一个观察向量 I ‾ \overline{I} I,一个法向量 N ‾ \overline{N} N,而这次是折射向量 R ‾ \overline{R} R。可以看到,观察向量的方向轻微弯曲了。弯折后的向量 R ‾ \overline{R} R 将会用来从立方体贴图中采样。

折射可以使用 GLSL 的内置 refract 函数来轻松实现,它需要一个法向量、一个观察方向和两种材质之间的折射率(Refractive Index)

折射率决定了材质中光线弯曲的程度,每种材质都有自己的折射率。一些最常见的折射率如下表所示:

材质折射率
空气1.00
1.33
1.309
玻璃1.52
钻石2.42

我们使用这些折射率来计算光在两种材质间传播的比率。在我们的例子中,光线/视线从空气进入玻璃(如果我们假设箱子是玻璃制的),所以比率为 1.00 / 1.52 = 0.658。

我们已经绑定了立方体贴图,提供了顶点数据和法线,并设置了摄像机位置的 uniform。唯一要修改的就是片段着色器:

void main() {             
    float ratio = 1.00 / 1.52;
    vec3 I = normalize(Position - cameraPos);
    vec3 R = refract(I, normalize(Normal), ratio);
    FragColor = vec4(texture(skybox, R).rgb, 1.0);
}

通过改变折射率,你可以创建完全不同的视觉效果。编译程序并运行,但结果并不是很有趣,因为我们只使用了一个简单的箱子,它不太能显示折射的效果,现在看起来只是有点像一个放大镜。对背包使用相同的着色器却能够展现出我们期待的效果:一个类玻璃的物体。

image.png

你可以想象出有了光照、反射、折射和顶点移动的正确组合,你可以创建出非常漂亮的水。注意,如果想要获得物理上精确的结果,我们还需要在光线离开物体的时候再次折射,现在我们使用的只是单面折射(Single-side Refraction),但它对大部分场合都是没问题的。

基础部分的源代码:立方体贴图basic - GitCode

动态环境贴图

目前我们使用的是静态图像组合而成的天空盒,效果尚可,但它无法在场景中包含可移动的物体。由于我们只使用了一个物体,所以一直没有注意到这一点。如果场景中有一个镜面物体,周围有多个其他物体,那么镜面物体中只能看到天空盒,仿佛场景中只有天空盒这一个物体。

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

虽然动态环境映射效果很好,但它有一个很大的缺点:我们需要为使用环境贴图的物体渲染场景六次,这对程序来说是非常大的性能开销。现代程序通常会尽可能使用天空盒,并在条件允许的情况下使用预编译的立方体贴图,只要它们能产生一些动态环境贴图的效果即可。虽然动态环境贴图是一项很棒的技术,但要在不降低性能的情况下使其正常工作,还需要很多技巧。


接下来简单实现一下动态环境贴图,基础场景有一个 backpack 和一个 cube,cube 随着时间在左右移动。backpack 应用的是折射,我们可以看到透过 backpack 是不能观察到 cube 的,最后我们期望透过 backpack 能够看到 cube 在移动。

PixPin_2025-02-15_18-25-35.gif

要想实现这个效果,我们就需要对 backpack 使用帧缓存,从六个不同角度创建场景纹理,然后使用这个动态生成的立方体贴图去渲染 backpack。

首先创建 6 个帧缓存:

unsigned int framebuffers[6];
glGenFramebuffers(6, framebuffers);

然后创建立方体贴图纹理,仅分配内存:

unsigned int frameBuffersTexture;
glGenTextures(1, &frameBuffersTexture);
glBindTexture(GL_TEXTURE_CUBE_MAP, frameBuffersTexture);
int width = 1024, height = 1024;
for (unsigned int i = 0; i < 6; i++)
{
	glTexImage2D(GL_TEXTURE_CUBE_MAP_POSITIVE_X + i,
		0, GL_RGB, width, height, 0, GL_RGB, GL_UNSIGNED_BYTE, nullptr
	);
}
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MAG_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);

接着创建 RBO

unsigned int rbos[6];
glGenRenderbuffers(6, rbos);

再将纹理与 RBO 附加到帧缓冲区

for (int i = 0; i < 6; i++)
{
	glBindFramebuffer(GL_FRAMEBUFFER, framebuffers[i]);
	glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_CUBE_MAP_POSITIVE_X + i, frameBuffersTexture, 0);
	glBindRenderbuffer(GL_RENDERBUFFER, rbos[i]);
	glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH24_STENCIL8, 1024, 1024);
	glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_STENCIL_ATTACHMENT, GL_RENDERBUFFER, rbos[i]);
	if (glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE)
		std::cout << "ERROR::FRAMEBUFFER:: Framebuffer is not complete!" << std::endl;

	glBindFramebuffer(GL_FRAMEBUFFER, 0);
	glBindRenderbuffer(GL_RENDERBUFFER, 0);
}

在渲染循环中,首先将相机朝向 6 个方向,将采集的图像绘制到纹理中。

for (int i = 0; i < 6; i++)
{
	glBindFramebuffer(GL_FRAMEBUFFER, framebuffers[i]);
	glClearColor(0.1f, 0.1f, 0.1f, 1.0f);
	glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
	glEnable(GL_DEPTH_TEST);
	glViewport(0, 0, 1024, 1024);

	// 新建一个临时相机以设置MVP
	Camera tempCamera{};
	tempCamera.Yaw = 0.f;
	switch (i) {
	case 0:
		tempCamera.Yaw = 180.f;
		break;
	case 1:
		tempCamera.Yaw = 0.f;
		break;
	case 2:
		tempCamera.Pitch = -90.0f;
		break;
	case 3:
		tempCamera.Pitch = 90.0f;
		break;
	case 4:
		tempCamera.Yaw = 90.f;
		break;
	case 5:
		tempCamera.Yaw = 270.f;
		break;
	}
	tempCamera.ProcessMouseMovement(0.f, 0.f);
	glm::mat4 view = tempCamera.GetViewMatrix();
	// 视角位于backpack中心点,采集一个正方体,fov应为90°,一个面的aspect为1:1
	glm::mat4 projection = glm::perspective(glm::radians(90.f), 1.f, 0.1f, 100.0f);

	// 渲染cube
	shader.use();
	glm::mat4 model = glm::mat4(1.0f);
	model = glm::translate(model, cubeLocation);
	shader.setMat4("model", model);
	shader.setMat4("view", view);
	shader.setMat4("projection", projection);
	glBindVertexArray(cubeVAO);
	glActiveTexture(GL_TEXTURE0);
	glBindTexture(GL_TEXTURE_2D, cubeTexture);
	glDrawArrays(GL_TRIANGLES, 0, 36);

	// 渲染天空盒
	glDepthMask(GL_FALSE);
	skyboxShader.use();
	view = glm::mat4(glm::mat3(tempCamera.GetViewMatrix()));
	skyboxShader.setMat4("view", view);
	skyboxShader.setMat4("projection", projection);
	glBindVertexArray(skyboxVAO);
	glBindTexture(GL_TEXTURE_CUBE_MAP, cubemapTexture);
	glDrawArrays(GL_TRIANGLES, 0, 36);
	glDepthMask(GL_TRUE);
}

这里要注意相机朝向的设置,顺序应当与天空盒加载顺序(右左上下前后)保持一致才对,但因为一些原因(后续解释),当 camera.Yaw = 0.f 时,对应的是右。对你没看错,前变成了右,所有相应的顺序全部变了,所以我采集图像的顺序变成了后前下上右左。至于这个上下顺序怎么也变了,后续解释。同时我注意到这样设置以后,backpack 中折射的天空盒上下左右颠倒了,看评论区猜测是:

cubemap 和普通 2d 纹理坐标对于 data 从左下角还是左上角那一行开始,约定完全是反的。by fd Jin

image.png

所以我在折射的 shader 中将 y 进行了镜像:

vec3 r = vec3(R.x, -R.y, R.z);
FragColor = vec4(texture(skybox, R).rgb, 1.0);

但还是存在问题:

image.png

x 方向居然也是镜像的!我仔细对比了渲染出来的天空盒与原图像的 x 方向,发现整个天空盒的 x 都是镜像的,原因查看评论区如下:

这是因为图片是贴在 cube 的外表面的。但摄像机位于 cube 内部看的 cube 的内表面。这就好比你直接看素材时是从图片上方向下看图片的正面,而在 skybox 里面,你是看图像的“背面“(假想图像是在透明的材料上),所以在渲染后你看到的图像和你直接看纹理图相比,是左右颠倒的。by fd Jin

这时候就可以解释当 camera. Yaw = 0. f 时,对应是右的原因:由于 x 的镜像,一个像素从最左侧变到了最右侧,相当于偏移了 90°,所以针对于 yaw 方向的变换,每次需要多 90°。

image.png

在 shader 中,我将 x 镜像:

vec3 r = vec3(-R.x, -R.y, R.z);
FragColor = vec4(texture(skybox, R).rgb, 1.0);

这下不存在大问题了,在折射率为 1 时,动态生成的环境贴图与背景完美重合:

image.png

当我以为完全胜利时,我发现从 backpack 的上朝下看或者下朝上看,还是会出现纹理不贴合的问题,这个问题是因为我直接对 R 的 x 与 y 取相反数来解决上下左右颠倒问题导致的,立方体贴图是一种 3D 纹理,R是一个方向向量,原点在立方体中心。我对 x 与 y 取相反数,对前后左右四个面进行采样时,可以做到镜像采样的效果,但是对于上下两个面来说,这样是没有效果的,想象一下一个指向上或下面的方向向量 xy 取相反数后,变化的方向向量指向哪里。我对 y 取相反数之后,还会导致上下两个面错位的情况,这也是我在生成环境贴图时,上下顺序变成了下上顺序。

image.png

最后就是对原场景的渲染,注意在渲染 backpack 的时候,使用动态生成的环境贴图就行了。

glActiveTexture(GL_TEXTURE0);
// 使用动态生成的贴图
glBindTexture(GL_TEXTURE_CUBE_MAP, frameBuffersTexture);

PixPin_2025-02-16_00-05-21.gif

上述我提到的问题,对动态环境贴图的内核影响不大,最后产生的效果还算可以,源代码:动态环境贴图 - GitCode

练习

  • 尝试在我们之前在模型加载小节中创建的模型加载器中引入反射贴图。你可以在这里找到升级后有反射贴图的机器人模型。仍有几点要注意的:
    • Assimp在大多数格式中都不太喜欢反射贴图,所以我们需要欺骗一下它,将反射贴图储存为漫反射贴图。你可以在加载材质的时候将反射贴图的纹理类型设置为aiTextureType_AMBIENT。
    • 我偷懒直接使用镜面光纹理图像来创建了反射贴图,所以反射贴图在模型的某些地方不能准确地映射:)。
    • 由于模型加载器本身就已经在着色器中占用了3个纹理单元了,你需要将天空盒绑定到第4个纹理单元上,因为我们要从同一个着色器中对天空盒采样。
  • 如果你都做对了,它会看起来像这样

首先在 model.cpp 中加上对反射贴图的加载

std::vector<Texture> ambientMaps = loadMaterialTextures(material, aiTextureType_AMBIENT, "texture_ambient");
textures.insert(textures.end(), ambientMaps.begin(), ambientMaps.end());

然后在 mesh. cpp 中修改起始的纹理单元(避免与天空盒纹理冲突),并添加对 ambient 纹理的设置

.....
unsigned int ambientNr = 1;
for (unsigned int i = 0; i < textures.size(); i++)
{
	glActiveTexture(GL_TEXTURE0 + i + 1); // 激活纹理单元
	.....
	else if(name == "texture_ambient")
		number = std::to_string(ambientNr++);
	......
}
.....

随后正常的加载并使用模型即可,顶点着色器如下:

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

out vec3 Normal;
out vec3 FragPos;
out vec2 TexCoords;
out vec3 ViewFragPos;

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

void main()
{
    gl_Position = projection * view * model * vec4(aPos, 1.0);
    Normal = mat3(transpose(inverse(model))) * aNormal;
    FragPos = vec3(model * vec4(aPos, 1.0));
    TexCoords = aTexCoords;
    ViewFragPos = vec3(view * vec4(FragPos, 1.0));
}

片段着色器如下:

#version 330 core

struct Material 
{
    sampler2D texture_diffuse1;
    sampler2D texture_specular1;
    sampler2D texture_ambient1;
};

uniform Material material;
uniform samplerCube skybox;
uniform vec3 cameraPos;

in vec3 Normal;
in vec3 FragPos;
in vec2 TexCoords;
in vec3 ViewFragPos;

out vec4 FragColor;

void main()
{
    vec3 norm = normalize(Normal);
    vec3 I = normalize(FragPos - cameraPos);
    vec3 R = reflect(I, normalize(Normal));
    vec4 reflectColor = vec4(texture(skybox, R).rgb, 1.0) * texture(material.texture_ambient1, TexCoords);
    vec4 specularColor = texture(material.texture_specular1, TexCoords);
    vec4 diffuseColor = texture(material.texture_diffuse1, TexCoords);
    // 加上diffuseColor无法渲染
    FragColor = reflectColor * 2 + specularColor + diffuseColor * 0.f;
}

image.png

可以看到在机器人的脑门上,有一点环境的反射效果。源码:立方体贴图练习 - GitCode

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

MustardJim

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值