4. 高级OpenGL¶
约 5035 个字 221 行代码 预计阅读时间 28 分钟
深度测试¶
在前面的章节中,我们通过如下语句开启和使用深度缓冲来防止被阻挡的面渲染到其它面前面:
当深度测试被启用时,OpenGL 会将一个片段的深度值(z)与深度缓冲的内容进行对比,如果深度测试通过的话,则更新深度缓冲。
深度缓冲运行在片段着色器之后的屏幕空间,我们可以通过 GLSL 内建函数 gl_FragCoord
从片段着色器中直接访问屏幕空间坐标,gl_FragCoord
中的 z 分量就是需要与深度缓冲对比的值。
Early Depth Testing
现在大部分的GPU都提供一个叫做提前深度测试(Early Depth Testing)的硬件特性。提前深度测试允许深度测试在片段着色器之前运行。只要我们清楚一个片段永远不会是可见的(它在其他物体之后),我们就能提前丢弃这个片段。
片段着色器通常开销都是很大的,所以我们应该尽可能避免运行它们。当使用提前深度测试时,片段着色器的一个限制是你不能写入片段的深度值。如果一个片段着色器对它的深度值进行了写入,提前深度测试是不可能的。OpenGL不能提前知道深度值。
OpenGL 允许我们修改深度测试时使用的比较符,我们可以通过 glDepthFunc
函数来设置:
这个函数接受下面表格中的比较运算符:
函数 | 描述 |
---|---|
GL_ALWAYS | 永远通过深度测试 |
GL_NEVER | 永远不通过深度测试 |
GL_LESS | 在片段深度值小于缓冲的深度值时通过测试 |
GL_EQUAL | 在片段深度值等于缓冲区的深度值时通过测试 |
GL_LEQUAL | 在片段深度值小于等于缓冲区的深度值时通过测试 |
GL_GREATER | 在片段深度值大于缓冲区的深度值时通过测试 |
GL_NOTEQUAL | 在片段深度值不等于缓冲区的深度值时通过测试 |
GL_GEQUAL | 在片段深度值大于等于缓冲区的深度值时通过测试 |
默认情况下使用的深度函数是 GL_LESS
,它将会丢弃深度值大于等于当前深度缓冲值的所有片段。
深度值的实际数值位于 [0,1]
之间,我们需要某种方程将屏幕坐标的 z 值转换为深度值,一种简单的想法是使用线性深度缓冲:
但是由于数据的精度限制,对于非常近的物体或者非常远的物体,它们深度测试逻辑正确的概率是相等的。但是,我们真的需要对1000单位远的深度值和只有1单位远的充满细节的物体使用相同的精度吗?线性方程并没有考虑这一点。
一个考虑了远近距离的方程为:
为了可视化这些深度数据的变化,我们可以将其输出为颜色:
这样我们就能注意到由近到远的物体,颜色从黑到白的非线性变化了。
gl_FragCoord.z
就是转换后的深度值
深度冲突
当两个面的深度值相同时,深度测试无法决定该显示哪一个,可能会出现两个面的不断切换显示顺序。
深度冲突是深度缓冲的一个常见问题,当物体在远处时效果会更明显(因为深度缓冲在z值较大时有更小的精度)。深度冲突不能够被完全避免。
对于防止深度冲突,一般有以下三种对策:
第一个也是最重要的技巧是永远不要把多个物体摆得太靠近,以至于它们的一些三角形会重叠。通过在两个物体之间设置一个用户无法注意到的偏移值,你可以完全避免这两个物体之间的深度冲突。在箱子和地板的例子中,我们可以将箱子沿着正y轴稍微移动一点。箱子位置的这点微小改变将不太可能被注意到,但它能够完全减少深度冲突的发生。然而,这需要对每个物体都手动调整,并且需要进行彻底的测试来保证场景中没有物体会产生深度冲突。
第二个技巧是尽可能将近平面设置远一些。在前面我们提到了精度在靠近近平面时是非常高的,所以如果我们将近平面远离观察者,我们将会对整个平截头体有着更大的精度。然而,将近平面设置太远将会导致近处的物体被裁剪掉,所以这通常需要实验和微调来决定最适合你的场景的近平面距离。
另外一个很好的技巧是牺牲一些性能,使用更高精度的深度缓冲。大部分深度缓冲的精度都是24位的,但现在大部分的显卡都支持32位的深度缓冲,这将会极大地提高精度。所以,牺牲掉一些性能,你就能获得更高精度的深度测试,减少深度冲突。
我们上面讨论的三个技术是最普遍也是很容易实现的抗深度冲突技术了。还有一些更复杂的技术,但它们依然不能完全消除深度冲突。深度冲突是一个常见的问题,但如果你组合使用了上面列举出来的技术,你可能不会再需要处理深度冲突了。
模板测试¶
当着色器处理完一个片段之后,并且在进行深度测试之前,模板测试开始运行。模板测试根据模板缓冲运行,它也可能会丢弃一些片段。
GLFW自动配置了模板缓冲,但是其它窗口库可能没有,需要查看对应库文档
模板缓冲的一个简单例子如下:
模板缓冲首先会被清除为0,之后在模板缓冲中使用1填充了一个空心矩形。场景中的片段将会只在片段的模板值为1的时候会被渲染(其它的都被丢弃了)。
模板缓冲操作允许我们在渲染片段时将模板缓冲设定为一个特定的值。通过在渲染时修改模板缓冲的内容,我们写入了模板缓冲。在同一个(或者接下来的)渲染迭代中,我们可以读取这些值,来决定丢弃还是保留某个片段。使用模板缓冲的时候你可以尽情发挥,但大体的步骤如下:
- 启用模板缓冲的写入。
- 渲染物体,更新模板缓冲的内容。
- 禁用模板缓冲的写入。
- 渲染(其它)物体,这次根据模板缓冲的内容丢弃特定的片段。
所以,通过使用模板缓冲,我们可以根据场景中已绘制的其它物体的片段,来决定是否丢弃特定的片段。
跟深度测试相同,我们通过以下代码启用和清空模板缓冲:
对于模板缓冲是否应该通过,有以下两个函数用来配置模板测试:
对于 glStencilFunc(func, ref, 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。
上一函数只告诉 OpenGL 如何进行比较,而 glStencilOp(sfail, dpfail, dppass)
则告诉 OpenGL 如何更新缓存:
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 | 按位翻转当前的模板缓冲值 |
在一些游戏中,我们常常需要为一个选中物体添加有色边框,而这个操作可以通过模板测试完成。为物体创建轮廓的步骤如下:
- 在绘制(需要添加轮廓的)物体之前,将模板函数设置为GL_ALWAYS,每当物体的片段被渲染时,将模板缓冲更新为1。
- 渲染物体。
- 禁用模板写入以及深度测试。
- 将每个物体缩放一点点。
- 使用一个不同的片段着色器,输出一个单独的(边框)颜色。
- 再次绘制物体,但只在它们片段的模板值不等于1时才绘制。
- 再次启用模板写入和深度测试。
这个过程将每个物体的片段的模板缓冲设置为1,当我们想要绘制边框的时候,我们主要绘制放大版本的物体中模板测试通过的部分,也就是物体的边框的位置。我们主要使用模板缓冲丢弃了放大版本中属于原物体片段的部分。
这里的禁用深度测试是为了不让轮廓被地板之类的物体遮挡,实际使用中可以考虑不禁用
当然,我们还可以应用高斯模糊之类的技术使得这些边框看上去更加自然:
混合¶
OpenGL 中,混合通常是用来实现透明的技术。
透明,即它的颜色是物体本身的颜色以及它背后物体颜色的结合
为了加载具有 alpha 值的纹理,我们只需要在纹理生成过程中声明一下:
同时,需要保证片段着色器中获取了纹理的全部四个分量:
那么一种手动丢弃透明片段的方式为,在片段着色器中设置一个阈值,丢弃低于这个阈值的片段:
discard 命令保证片段不会被进一步处理
但是对于半透明的对象,我们并不能简单的采用丢弃来处理。通过选项 GL_BLEND
来启动混合:
OpenGL 中的混合方程通过如下方程计算:
- \(\bar{C}_{source}\):源颜色向量,即纹理本身的颜色向量
- \(\bar{C}_{destination}\):目标颜色向量,即颜色缓冲中的颜色向量
- \(F_{source}\):源因子值,即 alpha 值对源颜色的影响
- \(F_{destination}\):目标因子值,即 alpha 值对目标颜色的影响
源颜色和目标颜色会由 OpenGL 自动决定,而因子值可由我们自由设定。可以通过 glBlendFunc
来设置源因子和目标因子:
选项 | 值 |
---|---|
GL_ZERO | 因子等于 0 |
GL_ONE | 因子等于 1 |
GL_SRC_COLOR | 因子等于源颜色向量 \(\bar{C}_{source}\) |
GL_ONE_MINUS_SRC_COLOR | 因子等于 \(1-\bar{C}_{source}\) |
GL_DST_COLOR | 因子等于目标颜色向量 \(\bar{C}_{destination}\) |
GL_ONE_MINUS_DST_COLOR | 因子等于 \(1- \bar{C}_{destination}\) |
GL_SRC_ALPHA | 因子等于 \(\bar{C}_{source}\) 的 alpha 分量 |
GL_ONE_MINUS_SRC_ALPHA | 因子等于 \(1-\bar{C}_{source}\) 的 alpha 分量 |
GL_DST_ALPHA | 因子等于 \(\bar{C}_{destination}\) 的 alpha 分量 |
GL_ONE_MINUS_DST_ALPHA | 因子等于 \(1-\bar{C}_{destination}\) 的 alpha 分量 |
GL_CONSTANT_COLOR | 因子等于常数颜色向量 \(\bar{C}_{constant}\) |
GL_ONE_MINUS_CONSTANT_COLOR | 因子等于 \(1-\bar{C}_{constant}\) |
GL_CONSTANT_ALPHA | 因子等于 \(\bar{C}_{constant}\) 的 alpha 分量 |
GL_ONE_MINUS_CONSTANT_ALPHA | 因子等于 \(1-\bar{C}_{constant}\) 的 alpha 分量 |
默认采用源颜色向量的 alpha 作为源因子,使用 1-alpha 作为目标因子:
深度测试和混合一起使用可能会导致透明部分遮蔽后面
深度测试和混合一起使用时可能会发生一些意想不到的麻烦。当写入深度缓冲时,Buffer 不会检查片段是否透明,即透明部分也会被写入深度缓冲中,此时后面的物体可能会被深度测试丢弃。
为了解决这个问题,可以手动调整渲染顺序,即从远到近进行绘制。不过对于草纹理这种周围全透明的物体,可以直接选择丢弃透明的片段而不是启用混合。
当绘制一个存在透明物体的场景时,大体的原则如下:
- 先绘制所有不透明的物体。
- 按照从远到近对所有透明的物体排序。
- 按顺序绘制所有透明的物体。
可以通过 STL 的 map 数据结构,根据键值 distance
进行从小到大排序:
在之后渲染的时候,只需要逆序取出即可:
它并没有考虑旋转、缩放等其它变换,这实际上是一个非常困难的技术
面剔除¶
举一个简单的例子,对于一个 3D 立方体,无论你从哪个方向看,最多也只能看到三个面。如果我们能以某种方式丢弃另外几个看不见的面,我们能省下超过 50% 的片段着色器执行数!
对于一个闭合形状,它的一个面都具有面向用户和背向用户的两侧,而面剔除(Face Culling)正是检查所有 Front Facing 面,并且丢弃 Back Facing 的面。
OpenGL 在渲染图元的时候根据三角形顶点的环绕顺序来确定一个三角形是正向还是背向的,默认情况下,逆时针顶点定义的三角形会被处理为正向三角形:
我们通过以下代码启用 OpenGL 的面剔除选项:
此时所有背向面在渲染时都会被剔除。
我们应该保证剔除仅对立方体这种封闭形状有效,某些面的正向和背向都应可见
OpenGL 允许我们改变剔除面的类型:
GL_BACK
:只剔除背向面。GL_FRONT
:只剔除正向面。GL_FRONT_AND_BACK
:剔除正向面和背向面。
帧缓冲¶
颜色缓冲、深度缓冲、模板缓冲等结合起来叫帧缓冲(FrameBuffer),它被存储与 GPU 内存中,并且可以由我们进行自定义操作。
上面的简单操作并不能简单创造出一个能够使用的帧缓冲,因为它还不完整:
- 附加至少一个缓冲(颜色、深度或模板缓冲)。
- 至少有一个颜色附件(Attachment)。
- 所有的附件都必须是完整的(保留了内存)。
- 每个缓冲都应该有相同的样本数(sample)。
从上面的条件中可以知道,我们需要为帧缓冲创建一些附件,并将附件附加到帧缓冲上。在完成所有的条件之后,我们可以以 GL_FRAMEBUFFER
为参数调用 glCheckFramebufferStatus
:
之后所有的渲染操作将会渲染到当前绑定帧缓冲的附件中。由于我们的帧缓冲不是默认帧缓冲,渲染指令将不会对窗口的视觉输出有任何影响。出于这个原因,渲染到一个不同的帧缓冲被叫做离屏渲染(Off-screen Rendering)。要保证所有的渲染操作在主窗口中有视觉效果,我们需要再次激活默认帧缓冲,将它绑定到0
。
在完成所有的帧缓冲操作之后,不要忘记删除这个帧缓冲对象:
在完整性检查执行之前,我们需要给帧缓冲附加一个附件。附件是一个内存位置,它能够作为帧缓冲的一个缓冲,可以将它想象为一个图像。当创建一个附件的时候我们有两个选项:纹理或渲染缓冲对象(Renderbuffer Object)。
为帧缓冲创建一个纹理和创建一个普通的纹理差不多:
主要的区别就是,我们将维度设置为了屏幕大小(尽管这不是必须的),并且我们给纹理的data
参数传递了NULL
。同样注意我们并不关心环绕方式或多级渐远纹理,我们在大多数情况下都不会需要它们。
我们仅仅分配了内存而没有填充它,填充这个纹理将会在我们渲染到帧缓冲之后来进行
最后,通过 glFramebufferTexture2D
附加到帧缓冲上:
glFrameBufferTexture2D
有以下的参数:
target
:帧缓冲的目标(绘制、读取或者两者皆有)attachment
:我们想要附加的附件类型。当前我们正在附加一个颜色附件。注意最后的0
意味着我们可以附加多个颜色附件。我们将在之后的教程中提到。textarget
:你希望附加的纹理类型texture
:要附加的纹理本身level
:多级渐远纹理的级别。我们将它保留为0。
中间摆了,直接跳到后期处理吧。总之,帧缓冲可用于创建镜子等效果。
1. 反相
2. 灰度
但是由于人眼对绿色更加敏感,对蓝色更不敏感,所以以下加权灰度值计算视觉效果更好:
3. 核效果
我们可以在当前纹理的周围区域采样,来创建出一些很有意思的效果。
核(Kernel,或卷积矩阵)是一个类矩阵的数值数组,它的中心为当前像素,并用核值乘上周围的像素值,并将结果相加变成一个值。下面以一个 3×3 的核作为例子展示效果:
这个例子中是一个锐化核,可以用来模拟一些游戏中打了麻醉针等道具的效果:
4. 模糊
模糊效果所用的核矩阵如下:
5. 边缘检测
这个核高亮了所有的边缘,而暗化了其它部分,在我们只关心图像的边角的时候是非常有用的。
立方体贴图¶
立方体贴图的创建和其它纹理相同,不过这次要绑定到 GL_TEXTURE_CUBE_MAP
上:
由于立方体有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 | 前 |
我们同样可以通过递增 1 的方式遍历这六个面
立方体贴图的环绕和过滤方式一般设置为:
而在立方体贴图的片段着色器中,我们也应使用不同类型的采样器进行采样,并且纹理坐标应是三维的:
天空盒就是一个包含了整个场景的大立方体,它包含周围环境的六个图像