面剔除、环绕方向与镜像变换

搞懂 GPU 如何按窗口坐标环绕在光栅化前剔除指定朝向,正确处理镜像变换、室内视角和双面材质,并用 GPU 计时验证真实收益。

学习目标

  • 能解释面剔除位于顶点处理之后、光栅化之前,因此省片段工作但不省 vertex shader
  • 能根据窗口坐标环绕与 glFrontFace 判断正背面,而不是误用法线或深度
  • 能配置 glEnable(GL_CULL_FACE)glCullFaceglFrontFace,并恢复每个 pass 的状态
  • 能检测负行列式模型变换造成的环绕翻转,为镜像实例修正 front-face 或索引
  • 能为封闭外壳、室内视角、双面材质和开放网格选择正确剔除策略,并用 GPU profiler 验证收益

为什么看不见的背面仍然会消耗 GPU

前几章那条流水线已经学会比远近、按镂空模板放行。但一个实心盒子摆在你面前时,它默认仍会把背对你的三角形送去铺色块。即使这些色块最后被深度测试挡住,前面的几何和覆盖工作已经发生。

这一章给流水线加一道省料工序:在真正动手铺色块之前,先扫一眼每个面是朝着你、还是背对着你,把指定朝向直接扔掉。对相机在外部的封闭盒子,通常可以省掉大量背面片段工作;但顶点处理已经完成,总帧时收益也取决于场景和 GPU。

没有它会怎样?在正常不透明渲染和深度测试下,盒子通常仍能正确显示,只是 GPU 可能做了更多光栅与片段工作。怎么认出「这个面朝你还是背你」?秘密藏在三角形三个顶点投影后的绕行方向里。

面剔除:背对你的面,干脆不画

这道省料工序就是 (face culling):GPU 按三角形投影后的朝向,把指定一侧的 primitive 在光栅化之前整体丢掉,不再产生覆盖样本或运行片段着色器。

更精确的顺序是:所有顶点先执行 vertex shader,primitive 装配并经过裁剪,随后根据窗口坐标有向面积判定正背并剔除,保留面才光栅化。被剔面连片段都不生成,所以主要节省光栅与 fragment shader;顶点成本已经发生,不能把剔除宣传成“整个三角形零成本”。

下面图用 X-ray/线框方式把本来会被深度遮住的背面显出来,目的是比较 GPU 提交了多少面,不是模拟普通不透明渲染的最终颜色:

正常深度测试下,封闭不透明立方体开关剔除后的最终图像通常相同:关闭时背面仍被处理,只是最终深度失败。是否安全不只取决于“封闭”,还取决于相机在外部、材质不需要双面效果、几何环绕一致。摄像机进入封闭网格内部时,原本的背面正是要看的内壁,策略必须切换。

环绕顺序:顶点绕一圈是逆时针还是顺时针

那 GPU 凭什么一眼认出「这个面朝你还是背你」?靠的是三角形三个顶点的 (winding order)——把三个顶点按定义的先后(v0 → v1 → v2)连起来走一圈,这一圈要么是逆时针(CCW,counter-clockwise)、要么是顺时针(CW,clockwise)。

这个方向完全由你定义顶点的顺序决定。同样三个位置的点,你先写左下还是先写右下,绕出来的方向就相反。一般约定建模时所有三角形都按逆时针定义顶点。下面这张图把「逆时针」和「顺时针」两种绕法并排画出来:

记住一个反直觉但关键的事实(也是整章的题眼):同一个三角形,从正面看是逆时针、从它背面看就变成顺时针——就像你正面看墙上一个顺时针画的钟,绕到墙背后透过去看,它就成了逆时针。GPU 利用的正是这一点。

正面与背面:投影到屏幕后看到的环绕方向

有了环绕顺序,区分正背面就水到渠成了。一个三角形投影到屏幕后,GPU 看它三个顶点在窗口坐标中的环绕方向:方向和 glFrontFace 约定一致(默认逆时针),它就是 (front face);方向相反,它就是 (back face)。

为什么这招管用?正是上一节那个事实:封闭物体上,朝向你的面投影后保持约定的逆时针(正面),而背对你的面是「从背后看」的,环绕自动反转成顺时针(背面)。于是 GPU 不需要任何额外信息,单看屏幕上的环绕方向,就能把朝你的和背你的分得清清楚楚。这个判定过程,下一节单步走一遍就彻底清楚了。

glCullFace 与 glFrontFace:剔谁、谁算正面

光会判定还不够,你得告诉 GPU 剔哪一面、以及哪种环绕算正面,这是两个独立的开关。先看决定剔掉哪一面的 ,默认是 GL_BACK(剔背面,也是绝大多数情况要的);再看决定哪种环绕算正面的 ,默认是 GL_CCW(逆时针为正面)。这两件套配上 glEnable(GL_CULL_FACE) 这个总开关,就是面剔除的全部 API。

glCullFace 可填的值就三个:

glCullFace(...)剔掉哪一面
GL_BACK只剔背面默认,最常用)
GL_FRONT只剔正面(反过来,留背面)
GL_FRONT_AND_BACK正面背面都剔(这个面什么都不画)

划重点:glFrontFace 的约定必须和完整变换后的窗口环绕一致。顺时针索引沿用默认 GL_CCW 会判反;逆时针索引经过镜像变换也可能得到同样结果。GPU 会把本想保留的面当成背面剔掉,物体可能消失或像内外翻。

镜像与负缩放:同一份网格也可能突然翻面

环绕不只由原始索引决定,还受完整变换链影响。普通平移、旋转和正尺度保持朝向;在一个轴上使用 scale.x = -1 做镜像,会把窗口几何左右翻转,CCW 变 CW。两个负尺度轴又翻转两次、恢复原方向。判断依据是模型变换左上 3×3 的行列式符号:负值表示奇数次镜像、环绕反转。

渲染镜像实例时有三种做法:为该 draw call 临时切换 glFrontFace(GL_CW);预处理一份反转三角形索引的 mesh;或在确实双面时关闭剔除。状态切换要按 draw 分组并在之后恢复。投影矩阵若人为翻转 Y,也会改变最终窗口环绕,因此跨 OpenGL/Vulkan 或离屏坐标约定时应通过一个已知三角形实测,而不是只看模型文件。

面剔除只看位置投影后的环绕,不读取顶点法线;法线反了会让光照错,却不会直接改变 culling。片段 shader 可用 gl_FrontFacing 区分当前 primitive 的朝向,为真正双面材质选择不同法线或颜色,但前提是该面没有在更早阶段被剔除。

单步走一遍:环绕顺序怎么定出正背面

把上面三节串起来,用一个立方体上「朝你的正面」和「背对你的背面」两个三角形,单步走完整套判定。

猜一猜:建模时这两个面的顶点都按同一个逆时针顺序定义。可它们一个朝你、一个背你。投影到屏幕后,这两个面看到的环绕方向会一样还是相反?哪个还是逆时针、哪个会反转成顺时针?先想一想,再单步走下面三步。

每一步左边画立方体的这两个面、右边写这一步对它们做了什么判定,盯着「背对你那个面的环绕方向怎么反转、最后怎么被划掉」,就懂了:

分步1 / 3

① 模型里:两个面顶点都按逆时针 CCW 定义

建模时定下一个统一约定:所有三角形都按 v0 → v1 → v2逆时针顺序写顶点。立方体上 朝你的正面背对你的背面 ,此刻在数据里都是这么定义的。注意——这一步只是 定义好了顶点顺序,还没判 谁正谁背;正背是投影到屏幕后才分出来的。

走完这一遍就明白:正背面不是建模时标死的,而是投影到屏幕后、按当时看到的环绕方向现判的。GPU 不需要任何额外标记,单靠「朝你的面保持逆时针、背你的面反转成顺时针」这一条几何事实,就把该剔的面认了出来。

对比一下:诊断视图里的剔除关 vs 开

猜一猜:下面故意用 X-ray/线框诊断方式显示“实际提交过的面”。从剔除关切到开,哪些背面 primitive 会消失?若恢复正常不透明材质和深度测试,最终颜色是否应该明显改变?

左边让诊断层画出正背两侧,右边只保留正面,因此能直观看到减少的 primitive。这个对比故意不等价于普通 opaque 渲染:真实场景中前面的深度会挡住背面,开关剔除通常不改变最终颜色,只改变 GPU 是否为背面执行光栅与片段工作。

开剔除后诊断视图里消失的是按窗口环绕判成 back-facing 的 primitives。对满足条件的封闭外壳,它应当“只省工作、不改最终图像”;如果正常 opaque 图像明显改变,优先检查环绕、负缩放、相机是否在内部,以及材质是否本来就要求双面。

代码对照:开启面剔除这三行

面剔除的代码短到不能再短——开总开关、(按需)指定剔哪面和哪种环绕算正面,剩下 GPU 全自动干。

// 初始化时开一次:默认是关的,不开就不会剔任何面
glEnable(GL_CULL_FACE);
 
// 下面两行其实都是默认值,写出来只为表明意图:
glCullFace(GL_BACK);    // 剔背面(默认就是 GL_BACK)
glFrontFace(GL_CCW);    // 逆时针环绕算正面(默认就是 GL_CCW)

逻辑两端一字对应:glEnable(GL_CULL_FACE) 打开总开关;glCullFace 选剔哪面、glFrontFace 选哪种环绕算正面。默认值适合环绕一致、无镜像的封闭外壳;室内视角、镜像实例和双面材质必须按 draw call 调整。GL_FRONT 也常用于只渲染内壁或某些阴影 pass,但它不是所有室内场景的固定答案。注意这里的 API 差异和那个最容易栽的前提:

镜像实例:按变换奇偶修正正面约定

若同一 mesh 既画普通实例又画镜像实例,应在每次绘制前根据变换奇偶选择正面约定,并在结束后恢复状态。实际引擎可预先把 mirrored 存进实例数据,再按该标志分组绘制,避免频繁切换状态。

const bool mirrored = glm::determinant(glm::mat3(model)) < 0.0f;
 
glFrontFace(mirrored ? GL_CW : GL_CCW);
glCullFace(GL_BACK);
DrawMesh();
 
// 恢复 pass 约定,避免影响后续 draw call。
glFrontFace(GL_CCW);

怎样证明剔除真的省到了

不要用“最终画面更干净”判断性能,因为正常 opaque 场景开关剔除后本就应当看起来一样。固定相机、分辨率和材质,分别测量关闭与开启剔除时的 GPU 时间;若分析器支持,再比较 submitted primitives、rasterized primitives 和 fragment invocations。

预期是:vertex shader invocations 基本不变,被光栅化的 primitive 与片段调用减少。减少幅度受可见朝向、遮挡、Early-Z、片段着色成本和硬件架构影响,不能把“约一半背面”直接换算成“帧时间快一倍”。对很小、已被 Early-Z 高效拒绝或顶点瓶颈的场景,收益可能很小,应以测量结果决定是否保留状态复杂度。

容易踩的坑

小结

  • 面剔除位于顶点处理之后、光栅化之前:省光栅与片段工作,不省已经执行的 vertex shader
  • GPU 按最终窗口坐标的 CCW / CW 环绕判定 front/back,不读取法线,也不比较深度
  • glEnable 开总开关,glCullFace 选剔哪侧,glFrontFace 定义哪种环绕算正面;每个 pass 都要管理和恢复状态
  • 镜像或奇数轴负缩放会翻转环绕,应切换 front-face 或反转索引;室内视角、开放网格和双面材质需要单独策略
  • 收益取决于场景、Early-Z 与 shader 成本;用 GPU 时间和 primitive/fragment 计数验证,不能按“背面约一半”推导帧时间

练习

问题 1(排错题:物体消失 / 破洞) 某人做了两件事都翻车了:① 给一个立方体开了面剔除,立方体却整个消失了;② 给一片随风摆的(一张单面纸片网格)开了同样的设置,草从背面看整个没了。这两个现象分别是什么原因?各自怎么改?

问题 2(问答题) 有人说「面剔除靠的是比较深度,把更远的面扔掉」。这个说法对吗?面剔除到底是在管线的哪一步、靠什么信息认出该剔的面?它和深度测试是同一回事吗?

问题 3(改设置 / 观察题) 在 §5 那个「剔除关 vs 开」对比里,默认是 glCullFace(GL_BACK)(剔背面)。如果把它改成 glCullFace(GL_FRONT)(改剔正面),对着一个封闭立方体看,画面会变成什么样?这种「剔正面」在什么场景下反而有用?

名词解释

本章出现的专业名词,用大白话再讲一遍。

面剔除

渲染管线在光栅化之前的一道优化工序:GPU 根据窗口环绕把指定朝向的 polygon 直接丢弃,不再为它们光栅化、跑片段着色器;已经发生的顶点处理不会被省掉。默认关闭,要手动 glEnable(GL_CULL_FACE) 开。详见本章「面剔除」一节。

环绕顺序

一个三角形的三个顶点,按定义的先后(v0 → v1 → v2)投影到窗口坐标后,要么是逆时针(CCW,counter-clockwise)、要么是顺时针(CW,clockwise)。原始索引决定起始绕向,镜像等变换还可能把它翻转;GPU 用最终结果判定正面 / 背面。详见本章「环绕顺序」一节。

正面

投影到屏幕后,三个顶点的环绕方向和你约定的方向(默认逆时针 CCW)一致的 polygon。默认剔背设置会保留它,但“正面”是环绕分类,不保证它在任意材质和相机位置下必然可见。详见本章「正面与背面」一节。

背面

投影到窗口坐标后,顶点环绕和 glFrontFace 约定相反的 polygon。默认 glCullFace(GL_BACK) 会丢弃它;室内视角、开放网格和双面材质则可能需要保留。详见本章「正面与背面」一节。

glCullFace

设置面剔除「剔掉哪一面」的函数,原型 glCullFace(mode)GL_BACK 只剔背面(默认,最常用)、GL_FRONT 只剔正面、GL_FRONT_AND_BACK 正背都剔(这个面就什么都不画了)。它只在面剔除已启用 glEnable(GL_CULL_FACE) 时才生效。详见本章「glCullFace 与 glFrontFace」一节。

glFrontFace

设置「哪种环绕方向算正面」的函数,原型 glFrontFace(mode)GL_CCW 表示逆时针环绕为正面(默认)、GL_CW 表示顺时针环绕为正面。它必须和完整变换后的窗口环绕一致——原始索引正确但镜像变换未修正时,正面仍会被当背面剔掉。详见本章「glCullFace 与 glFrontFace」一节。

讨论

评论区加载中…