1. 从一个“消失的面”说起:正背面剔除与深度缓冲到底在解决什么
如果你写过一段时间OpenGL,大概率遇到过这种场景:画了一个立方体,旋转起来之后发现某些面莫名其妙消失了,或者后面的面跑到前面来了,整个模型看起来像被撕碎了一样。更诡异的是,有时候明明两个物体前后摆放,渲染出来却是后面的盖住了前面的。这些问题不是你的顶点数据写错了,也不是着色器有bug,而是渲染管线里两个非常基础但极其关键的机制没有配置好——正背面剔除和深度缓冲。
这两个东西听起来像是两个独立的知识点,但实际上它们解决的是同一个大问题的两个侧面:如何让GPU只画该画的东西,并且画对前后顺序。正背面剔除管的是“哪些面根本不需要画”,深度缓冲管的是“同一个像素位置上谁该显示在前面”。一个负责减少工作量,一个负责保证正确性。你把这两个搞定了,3D场景的渲染才算真正立起来了。
这篇文章我会从实际代码出发,把这两个机制的来龙去脉讲清楚。适合已经能画出三角形、但对3D渲染秩序还不太有把握的开发者。读完你至少能搞明白三件事:为什么旋转的立方体会缺面、深度测试到底在哪个环节生效、以及怎么用最少的代码把这两个机制配到位。
2. 正背面剔除:让GPU少干一半的活
2.1 什么是“正面”和“背面”,GPU怎么判断
在OpenGL的世界里,所有的面都是三角形。一个三角形有三个顶点,这三个顶点在屏幕上投影之后,会形成一个环绕方向——要么是顺时针,要么是逆时针。OpenGL默认约定:逆时针环绕的面是正面,顺时针环绕的面是背面。
这个约定不是随便定的。你想想,一个封闭的物体,比如立方体,它的外表面从外面看过去,顶点顺序通常是逆时针的;而内表面从外面看过去,因为被翻过来了,顶点顺序就变成了顺时针。所以GPU只要检查每个三角形的环绕方向,就能判断它是朝向摄像机还是背对摄像机。
判断的具体做法在光栅化阶段。GPU拿到三个顶点的屏幕坐标后,算一下这个三角形的有向面积。如果面积是正的(按OpenGL的坐标系约定),就是逆时针,正面;面积是负的,就是顺时针,背面。这个过程非常快,就是几个乘法和减法,但省下来的可是整个三角形的光栅化和片段着色开销。
2.2 为什么必须开剔除:一个立方体的账
假设你画一个立方体,6个面,每个面2个三角形,一共12个三角形。如果不做任何剔除,这12个三角形全部会进入光栅化阶段,然后每个三角形覆盖的像素都会跑一遍片段着色器。但实际上,从任何一个角度看过去,你最多只能看到3个面,也就是6个三角形。另外6个三角形是背对你的,它们的片段着色器结果最终会被前面的面覆盖掉,纯属白算。
这还只是一个立方体。如果场景里有几千个物体,每个物体几百个三角形,不开剔除的话,GPU有一半的算力都浪费在那些永远看不见的面上。正背面剔除就是把这个浪费直接砍掉——背面三角形在光栅化之前就被丢弃了,连片段着色器都不会进。
注意:正背面剔除只对封闭的、不透明的物体有效。如果你的模型是单面片(比如树叶、旗帜),或者需要看到物体内部,那就不能开剔除,否则该显示的面也会被丢掉。
2.3 代码层面怎么配:三步搞定
配置正背面剔除其实就三行代码的事,但顺序和参数不能错。
// 第一步:启用剔除功能 glEnable(GL_CULL_FACE); // 第二步:指定剔除哪一面,默认是背面 glCullFace(GL_BACK); // 第三步:指定正面环绕方向,默认是逆时针 glFrontFace(GL_CCW);这三行代码里,glCullFace的参数可以是GL_BACK、GL_FRONT或者GL_FRONT_AND_BACK。绝大多数情况下你用GL_BACK就够了,也就是把背面丢掉。GL_FRONT_AND_BACK会把所有面都丢掉,听起来很蠢,但有时候调试的时候想看看某个物体是不是真的在渲染,可以临时用一下。
glFrontFace的参数是GL_CCW或GL_CW。如果你从某个建模软件导出的模型,旋转起来发现该显示的面被剔掉了,不该显示的反而在,那大概率是顶点环绕方向和OpenGL默认约定反了。这时候不用去改模型数据,直接把glFrontFace(GL_CW)设一下就行。
2.4 实操心得:什么时候不该开剔除
我踩过的一个坑是做一个粒子系统,每个粒子是一个面向摄像机的小四边形。这种billboard技术生成的四边形,顶点顺序是固定的,但摄像机一转,有些四边形的环绕方向就变成顺时针了。如果开了背面剔除,这些粒子就会突然消失。解决办法要么是关掉剔除,要么是在生成四边形的时候根据摄像机方向动态调整顶点顺序。前者简单但费性能,后者麻烦但高效。对于粒子数量不多的情况,直接关掉剔除是最省事的。
还有一个场景是阴影贴图的渲染。渲染阴影贴图的时候,通常需要剔除正面而不是背面,这样可以减少阴影偏移(shadow acne)的问题。这个属于进阶技巧,后面讲深度缓冲的时候会再提到。
3. 深度缓冲:解决“谁在前面”的终极方案
3.1 没有深度缓冲的世界:画家算法的局限
在深度缓冲出现之前,渲染3D场景用的是画家算法——先画远处的物体,再画近处的物体,近处的覆盖远处的,看起来就有前后关系了。这个思路很直观,但问题一大堆。
首先,物体之间的远近关系不是总能排出一个全序。三个三角形互相穿插的时候,你没法说A在B前面、B在C前面、C又在A前面,这就是循环遮挡。其次,即使能排序,排序本身也要花时间,场景一复杂排序开销就上去了。再者,画家算法是按物体排序的,但一个物体内部的面之间也可能有前后关系,按物体排根本解决不了。
深度缓冲的思路完全不一样:我不排序,我给每个像素记一个深度值。谁离摄像机近,谁就留下。
3.2 深度缓冲的工作原理:逐像素的深度测试
深度缓冲是一块和颜色缓冲区同样大小的内存区域,每个像素对应一个深度值。默认情况下,深度值范围是0到1,0对应近裁剪面,1对应远裁剪面。
渲染每个片段的时候,GPU会做这么几件事:
- 计算这个片段在屏幕上的位置(x, y)和它的深度值z。
- 读取深度缓冲在(x, y)处的当前值,记为depth_buffer[x][y]。
- 比较z和depth_buffer[x][y]。
- 如果z小于depth_buffer[x][y](也就是更近),就通过测试,把颜色写进颜色缓冲区,同时把depth_buffer[x][y]更新为z。
- 如果z大于等于depth_buffer[x][y],就丢弃这个片段,什么都不写。
这个过程是逐片段的,不是逐物体的。所以不管你的三角形怎么穿插,只要每个片段都做一次深度测试,最终屏幕上留下的就是每个像素位置上最近的那个片段。循环遮挡的问题自然就没了。
3.3 深度测试的配置:glDepthFunc的八种模式
OpenGL默认的深度测试函数是GL_LESS,也就是“如果新片段的深度小于缓冲区里的深度,就通过”。这个默认值适用于绝大多数场景。但glDepthFunc其实支持八种比较模式:
| 模式 | 含义 | 典型用途 |
|---|---|---|
| GL_NEVER | 永远不通过 | 调试用,什么都不画 |
| GL_LESS | 新深度 < 旧深度时通过 | 默认,标准3D渲染 |
| GL_EQUAL | 新深度 == 旧深度时通过 | 多pass渲染,第二遍只画相同深度的 |
| GL_LEQUAL | 新深度 <= 旧深度时通过 | 常用,配合多边形偏移 |
| GL_GREATER | 新深度 > 旧深度时通过 | 反向深度缓冲 |
| GL_NOTEQUAL | 新深度 != 旧深度时通过 | 特殊效果 |
| GL_GEQUAL | 新深度 >= 旧深度时通过 | 反向深度缓冲配合LEQUAL |
| GL_ALWAYS | 永远通过 | 关闭深度测试的效果 |
大部分情况下你用GL_LESS就行。但有一个场景必须用GL_LEQUAL:当你画一个物体的多个pass,比如先画基础颜色再画描边,两个pass的深度值完全一样,用GL_LESS的话第二个pass会被丢掉,用GL_LEQUAL才能让第二个pass通过。
3.4 深度缓冲的初始化和清除
深度缓冲不是自动就有的。如果你用的是GLFW或者SDL创建窗口,需要在创建的时候申请深度缓冲。以GLFW为例:
glfwWindowHint(GLFW_DEPTH_BITS, 24);这行代码告诉GLFW给窗口分配24位的深度缓冲。24位意味着深度值的精度是2的24次方分之一,对于大多数场景足够了。如果场景特别大,远近裁剪面差距悬殊,可能需要32位深度缓冲,但一般用不上。
每帧渲染之前,必须清除深度缓冲:
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);如果你忘了清深度缓冲,上一帧的深度值还在里面,这一帧的片段就会和上一帧的深度做比较,结果就是画面闪烁或者物体消失。这个坑我踩过不止一次,尤其是刚开始学的时候,只记得清颜色忘了清深度,调半天调不出来。
注意:
glClear的深度清除值默认是1.0,也就是最远。你可以用glClearDepth改这个值,但一般不需要动。
4. 两个机制配合使用:完整实操流程
4.1 渲染循环里的标准配置顺序
把正背面剔除和深度缓冲配到一起,一个标准的渲染循环大概长这样:
// 初始化阶段(只做一次) glEnable(GL_DEPTH_TEST); glDepthFunc(GL_LESS); glEnable(GL_CULL_FACE); glCullFace(GL_BACK); glFrontFace(GL_CCW); // 每帧渲染 glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); // 画不透明物体 drawOpaqueObjects(); // 画透明物体(需要特殊处理,后面讲) drawTransparentObjects();顺序很重要。glEnable(GL_DEPTH_TEST)必须在任何绘制之前调用,否则深度测试不生效。glClear必须在每帧绘制之前调用,而且颜色和深度都要清。
4.2 透明物体的处理:深度缓冲的软肋
深度缓冲对不透明物体完美,但遇到透明物体就麻烦了。假设你有一个透明的玻璃球,深度测试会直接把球后面的物体丢掉,因为球的深度更近。但透明物体应该是可以看穿到后面的,这就矛盾了。
标准做法是:先画所有不透明物体,深度缓冲正常写入;然后画透明物体,这时候要关闭深度写入但保持深度测试。
// 画完不透明物体后 glDepthMask(GL_FALSE); // 关闭深度写入 glEnable(GL_BLEND); glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); // 画透明物体,按从远到近排序 drawTransparentObjects(); // 恢复状态 glDepthMask(GL_TRUE); glDisable(GL_BLEND);glDepthMask(GL_FALSE)的意思是:深度测试照常做,但通过测试的片段不更新深度缓冲。这样透明物体之间不会互相遮挡,同时透明物体和不透明物体之间的前后关系还是正确的。透明物体内部还需要按从远到近排序,这个排序是在CPU端做的,按物体中心到摄像机的距离排。
4.3 深度冲突与多边形偏移
两个面靠得特别近的时候,深度值可能因为精度不够而分不清谁前谁后,导致画面出现闪烁的条纹。这就是深度冲突(z-fighting)。
常见的场景是画一个地板,然后在上面贴一层花纹。地板和花纹的深度几乎一样,GPU分不清。解决办法是给花纹加一个微小的深度偏移:
glEnable(GL_POLYGON_OFFSET_FILL); glPolygonOffset(-1.0f, -1.0f); // 画花纹 glDisable(GL_POLYGON_OFFSET_FILL);glPolygonOffset的两个参数,第一个是乘数因子,第二个是常数项。最终偏移量 = 因子 × 深度斜率 + 常数 × 最小可分辨深度值。负值让物体更靠近摄像机,正值让它更远。具体调多少要看场景,一般-1.0到-4.0之间试。
另一个缓解深度冲突的办法是调整投影矩阵的远近裁剪面。近裁剪面别设太小,远裁剪面别设太大。比如近裁剪面0.1、远裁剪面1000,深度精度就比近裁剪面0.001、远裁剪面10000要好得多。因为深度缓冲的精度分布不是线性的,近处精度高,远处精度低,近裁剪面越小,远处精度损失越严重。
4.4 反向深度缓冲:一个提升精度的技巧
如果你的场景特别大,从几厘米到几公里都有,标准深度缓冲的精度可能不够用。这时候可以用反向深度缓冲:把深度范围从[0, 1]反过来,近处是1,远处是0,同时深度测试函数改成GL_GREATER。
glDepthFunc(GL_GREATER); glClearDepth(0.0); glClear(GL_DEPTH_BUFFER_BIT); // 投影矩阵也要改,把近远裁剪面的映射反过来反向深度缓冲的好处是,浮点数在0附近的精度比在1附近高,而远处物体正好映射到0附近,所以远处的深度精度反而更高了。这个技巧在大型开放世界游戏里很常用,但如果你只是画个小场景,没必要折腾。
5. 常见问题与排查技巧实录
5.1 物体旋转时缺面:先查剔除,再查环绕方向
这是最经典的问题。一个立方体转起来,某些角度少了一两个面。排查步骤:
- 先确认
glEnable(GL_CULL_FACE)是不是开了。如果开了,试着临时关掉,看看面是不是都回来了。如果关掉就正常,那就是剔除的问题。 - 如果关掉剔除正常,检查
glFrontFace的设置。默认是GL_CCW,如果你的模型顶点是顺时针定义的,就会把正面当背面剔掉。改成GL_CW试试。 - 如果改了
glFrontFace还是不对,检查顶点数据的索引顺序。用工具把模型线框画出来,看看三角形的环绕方向是不是一致的。有些模型导出的时候面片方向就是乱的,这种只能修模型。
5.2 后面的物体盖住了前面的:深度测试没开或没清
如果两个物体前后摆放,渲染出来后面的在前面,九成是深度测试的问题:
- 检查
glEnable(GL_DEPTH_TEST)有没有调用。这个是最容易忘的,尤其是从2D转到3D的时候。 - 检查每帧有没有
glClear(GL_DEPTH_BUFFER_BIT)。只清颜色不清深度,上一帧的深度值会干扰这一帧。 - 检查窗口创建时有没有申请深度缓冲。有些窗口库默认不创建深度缓冲,需要显式指定。
- 检查
glDepthFunc是不是被改成了GL_ALWAYS或者GL_NEVER。这两个极端值都会导致深度测试失效。
5.3 透明物体渲染异常:深度写入没关
透明物体如果开了深度写入,会出现两种情况:要么透明物体后面的东西完全看不见,要么透明物体之间互相遮挡得很奇怪。解决办法就是前面说的,画透明物体之前glDepthMask(GL_FALSE),画完再恢复。
还有一个常见问题是透明物体没有排序。两个透明物体重叠的时候,如果不按从远到近画,混合结果就是错的。排序的代码很简单,算每个物体中心到摄像机的距离,从大到小排一下就行。但要注意,如果透明物体很大或者互相穿插,按中心排序也不准,这种极端情况需要用逐片段的排序或者深度剥离,那就复杂了。
5.4 深度冲突速查表
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 两个面交替闪烁 | 深度精度不足 | 调整远近裁剪面,或加多边形偏移 |
| 远处物体出现条纹 | 远裁剪面太大 | 缩小远裁剪面,或开反向深度缓冲 |
| 贴花与底面闪烁 | 深度值太接近 | 用glPolygonOffset给贴花加偏移 |
| 阴影边缘有噪点 | 阴影贴图深度冲突 | 渲染阴影贴图时剔除正面,或加偏移 |
| 整个场景深度错乱 | 深度缓冲没清 | 每帧glClear加GL_DEPTH_BUFFER_BIT |
5.5 一个容易被忽略的坑:视口和深度范围
如果你用了多个视口,或者渲染到帧缓冲对象(FBO),深度范围可能会变。默认的深度范围是0到1,但你可以用glDepthRange改。渲染到FBO的时候,如果FBO没有深度附件,深度测试就完全失效。这个坑在做后期处理的时候特别容易踩——先把场景渲染到FBO,再做后期,结果FBO忘了加深度附件,整个场景的前后关系全乱了。
提示:创建FBO的时候,记得加一个深度附件,可以是renderbuffer也可以是texture。如果不需要在后期里读深度值,用renderbuffer更省内存。
6. 性能考量:什么时候该开,什么时候该关
正背面剔除和深度缓冲都不是免费的。剔除本身有计算开销,深度测试也有内存带宽的消耗。但在绝大多数场景下,它们省下来的远比花掉的多。
正背面剔除在封闭物体多的场景里收益巨大,能省掉将近一半的片段着色。但如果你的场景全是单面片,比如植被、粒子,开剔除反而会导致画面错误,那就得关掉。深度测试在几乎所有3D场景里都应该开,除非你在做纯2D渲染或者有特殊的渲染顺序需求。
有一个性能技巧是提前深度测试(early-z)。现代GPU会在片段着色器之前先做深度测试,如果不通过就直接丢弃片段,连着色器都不跑。但这个优化有个前提:你的片段着色器不能写深度值(不能改gl_FragDepth),也不能丢弃片段(不能用discard)。如果你在着色器里用了discard,early-z就失效了,深度测试会推迟到着色器之后,性能会下降。所以如果不是必须,尽量少用discard。
还有一个技巧是深度预pass。先用一个极简的着色器只写深度,不写颜色,把整个场景的深度缓冲建好。然后再正常渲染一遍,这时候因为深度缓冲已经有了,大部分片段会在early-z阶段被丢掉,只有真正可见的片段才会跑完整的着色器。这个技巧在片段着色器很复杂的时候特别有效,比如有大量光照计算或者后处理的时候。代价是几何体要画两遍,所以适合几何体不多但着色器很重的情况。
7. 我个人在实际操作中的几点体会
调这两个机制的时候,我最深的体会是:先保证正确,再优化性能。刚开始学的时候,我总想着一步到位把剔除和深度都配上,结果出了问题不知道是哪个环节的锅。后来学乖了,先只开深度测试,确认前后关系对了;再开剔除,确认面没少;最后再调各种偏移和精度。一步一步来,出问题好定位。
另一个体会是,线框模式是排查这类问题的神器。glPolygonMode(GL_FRONT_AND_BACK, GL_LINE)一开,所有三角形的边都画出来,环绕方向一目了然。哪个面被剔了、哪个面深度不对,线框模式下看得清清楚楚。调好了再切回填充模式。
最后分享一个小技巧:如果你不确定某个物体该不该开剔除,可以先用glCullFace(GL_FRONT_AND_BACK)把所有面都剔掉,然后慢慢放开。先放开背面,看看正面是不是你要的;如果反了,就改glFrontFace。这个方法比盲猜快得多。
深度缓冲的精度问题,很多时候不是调参数能解决的,而是要从场景设计上规避。比如远近裁剪面别设得太夸张,物体别做得太薄,贴花别贴得太近。这些在建模阶段就注意,比后期调偏移省事得多。