需要预先学习:


1. 剔除解决什么问题

真实场景的三角形动辄百万千万,但屏幕只有几百万像素,一帧里真正可见的几何只占极小一部分。剔除(culling)就是在管线的不同阶段尽早把"反正看不见"的几何丢掉,让 GPU 不做无用功。剔除发生得越早,省下的越多:一个物体在 CPU 侧被剔除,顶点变换、光栅化、片元着色全省;一个片元在着色前被剔除,至少省下最贵的片元着色。

按发生的位置,剔除技术可以分层:

层次 执行者 粒度 代表技术
应用层 CPU 物体 / 实例 视锥剔除、遮挡剔除、小物体剔除
图形管线固定阶段 GPU 硬件 三角形 / 片元 裁剪、背面剔除、Early-Z
GPU 计算阶段(现代) compute shader 物体 / 实例 / 三角形 Hi-Z 遮挡剔除、GPU 驱动管线

每一层的输入都是上一层筛剩下的,层层过滤。这篇笔记按"从物体到片元"的顺序把这些技术串起来。


2. 视锥剔除(Frustum Culling)

视锥剔除(frustum culling)发生在 CPU 提交 draw call 之前:完全在视锥体(frustum,见 视图(摄像机)与投影)之外的物体不可能出现在屏幕上,直接不提交绘制。

2.1 视锥体的六个平面

视锥体由六个平面围成:左、右、上、下、近平面、远平面。平面用平面方程表示:

π:ax+by+cz+d=0,n⃗=(a,b,c)\pi: ax + by + cz + d = 0, \quad \vec{n} = (a, b, c)

点 PP 代入 f(P)=ax+by+cz+df(P) = ax + by + cz + d,符号说明它在平面的哪一侧(视锥内部为正,n⃗\vec{n} 取指向锥内的方向)。

这六个平面不需要单独构造——直接从组合矩阵 M=P⋅VM = P \cdot V(投影乘视图)的行向量线性组合提取,这是经典的 Gribb-Hartmann 方法(行向量约定下):

平面 提取式
左平面 row4+row1row_4 + row_1
右平面 row4−row1row_4 - row_1
下平面 row4+row2row_4 + row_2
上平面 row4−row2row_4 - row_2
近平面 row4+row3row_4 + row_3
远平面 row4−row3row_4 - row_3

每个提取出的四维向量 (a,b,c,d)(a, b, c, d) 就是一个平面方程(使用前通常归一化,让 a2+b2+c2=1\sqrt{a^2+b^2+c^2} = 1,方便后面按距离比较)。

2.2 包围体:球与 AABB

逐三角形测试太贵,视锥剔除针对的是物体整体。给每个物体预先算一个粗糙的包围体(bounding volume):

  • 包围球(bounding sphere):中心 cc、半径 rr。测试最简单:点到平面的距离 n⃗⋅c+d\vec{n} \cdot c + d,若小于 −r-r,球整体在平面外侧:
// 包围球与平面的测试:在外侧则整个球不可见
bool sphereOutside(const Plane& p, const Sphere& s)
{
    return dot(p.n, s.center) + p.d < -s.radius;
}
  • AABB(Axis-Aligned Bounding Box,轴对齐包围盒):区间 [min,max][\text{min}, \text{max}] 之间的长方体,对细长物体的贴合度通常好于包围球。

2.3 平面-AABB 测试

AABB 与平面的测试有个漂亮结论:不用测八个角点,只测一个角。取 AABB 在平面法线正方向上最远的那个角——正顶点(positive vertex):

// 平面与 AABB 的测试:正顶点也在平面外侧 -> 整个盒子在外侧
glm::vec3 pvertex(
    p.n.x >= 0 ? aabb.max.x : aabb.min.x,
    p.n.y >= 0 ? aabb.max.y : aabb.min.y,
    p.n.z >= 0 ? aabb.max.z : aabb.min.z);

bool boxOutside = dot(p.n, pvertex) + p.d < 0;

道理很直接:盒子里所有点在法线方向上的投影都不超过正顶点,正顶点都在平面外侧(f<0f < 0)时,其他点更在外面。测试结果三态:

正顶点代入值 结论 处理
<0< 0 盒子整体在平面外侧 剔除,直接跳过后续平面
≥0\ge 0 且反顶点 <0< 0 相交 保守当作可见
反顶点也 ≥0\ge 0 盒子整体在锥内 可见,且剩余平面只需测正顶点一侧

六个平面都通过(都不是"整体在外"),物体才可能可见。

注意:剔除必须宁可错放,不可错杀——把有一丝可见可能的物体剔掉,画面就会"凭空消失"。AABB 比物体本身大、相交情况当作可见,这些"保守"都是正确行为,付出的只是多画几个物体的代价。

2.4 层次化剔除

场景里物体成千上万时逐个测试六个平面也不便宜。用层次包围结构(BVH、八叉树、场景图)把物体组织成树,每个节点有自己的包围体:节点整体在外侧,整棵子树一次跳过,测试次数从 O(n)O(n) 降到 O(log⁡n)O(\log n)。


3. 背面剔除(Back-face Culling)

封闭网格中大约一半的三角形背对摄像机(比如立方体的背面三个面)。背面剔除(back-face culling)把这些三角形在光栅化之前直接丢弃,省掉它们的全部光栅化与着色。

3.1 原理:环绕顺序

三角形投影到屏幕后,顶点的环绕顺序(winding order,顺时针或逆时针)暴露了它朝向哪边:正面三角形投影后顶点逆时针(以 OpenGL 默认约定),背面三角形顺时针。判断依据与 光栅化 中"点是否在三角形内"的叉积同源——三角形面积的有向二倍:

2A=(xB−xA)(yC−yA)−(yB−yA)(xC−xA)2A = (x_B - x_A)(y_C - y_A) - (y_B - y_A)(x_C - x_A)

符号为负说明环绕方向翻转了,是背面。

3.2 OpenGL 中的用法

glEnable(GL_CULL_FACE);     // 默认是关闭的
glCullFace(GL_BACK);        // 剔除背面(默认值)
glFrontFace(GL_CCW);        // 逆时针为正面(默认值)

两个常见的坑:

  1. 建模时环绕顺序必须一致。模型导出、索引重排、面片拼接中只要有一批三角形顶点顺序反了,那批面就会"消失"或"内外翻转";
  2. 镜像缩放会翻转环绕。对物体用了负的缩放矩阵(镜像),原来逆时针的投影变顺时针,所有面内外翻转——经典 bug。建模软件里"法线翻了"很多时候就是它。

对双面几何(叶子、布料、纸片),要么关掉剔除,要么渲染两遍(第二遍 glCullFace(GL_FRONT) 只画背面并用翻转的法线着色)。


4. Early-Z:提前的深度测试

深度缓冲 的流程里,深度测试排在片元着色器之后。按这个顺序,被遮挡的片元也要完整跑一遍片元着色器,最后才被丢弃——白白浪费最贵的阶段(前向渲染与延迟渲染 的过度绘制问题就是这么来的)。

Early-Z(Early Depth Test,提前深度测试)是硬件的优化:在光栅化之后、片元着色器之前先做一次深度测试,通不过的片元连着色器都不进。

4.1 生效条件

硬件无法预知着色器的行为,只有确认"着色不影响深度"时才敢提前测试。以下任一情况会让 Early-Z 失效(回退到着色后测试):

  • 片元着色器使用了 discard(alpha test);
  • 片元着色器写入了 gl_FragDepth;
  • 开启了会改变深度的状态(如深度偏移的某些用法)。

这也是镂空纹理(alpha test 材质)在大面积使用时性能骤降的原因之一——不仅自己失去 Early-Z,还会破坏同一像素上后续片元的提前剔除(见 半透明物体渲染)。

4.2 绘制顺序的影响

Early-Z 的收益取决于深度缓冲里先有什么:

  • 从近到远绘制:近处的片元先写入深度,后面所有更远的片元都被提前剔除,着色只发生在最表面;
  • 从远到近绘制:每个片元到达时深度缓冲里还是"更远的深度",测试全部通过,全部完整着色,Early-Z 形同虚设。

所以不透明物体的绘制排序"近到远"(front-to-back)是常见的应用层配合手段——draw call 按视距排序,本身开销不大,收益却作用于整个片元阶段。


5. Z-Prepass:深度预通道

Early-Z 只能利用"已经画进深度缓冲的内容"。那就主动一点:先用一遍最便宜的着色器把整个场景的深度画出来,再进行真正的着色。这就是 Z-Prepass(深度预通道):

// Pass 1:深度预通道——顶点变换 + 空片元着色器,只写深度
glUseProgram(depthOnlyShader);          // 片元着色器是空的
glColorMask(GL_FALSE, GL_FALSE, GL_FALSE, GL_FALSE);  // 不写颜色
drawScene();
glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE);

// Pass 2:正常着色——几乎每个片元都被 Early-Z 剔除,只着色可见表面
glDepthMask(GL_FALSE);                  // 深度已正确,Pass 2 不再写深度
glEnable(GL_DEPTH_TEST);
glUseProgram(mainShader);
drawScene();

两遍绘制之间,深度测试确保第二遍的片元着色只发生在最终可见的表面——即使绘制顺序完全乱序,也等效于最优的 front-to-back。

5.1 收益与代价

  • 收益:片元着色(最贵的部分)几乎零浪费,对前向渲染的多光源、复杂材质管线尤其划算;
  • 代价:几何阶段翻倍——顶点变换、图元装配、光栅化全部做两遍。几何简单、片元着色贵(多 pass 材质、复杂光照)时赚;几何极重(海量三角形)时可能倒贴;
  • 延迟渲染天然不用它:G-Buffer 的几何 pass 本身就是"带属性的深度 pass",光照已经推迟到屏幕空间(见 前向渲染与延迟渲染)。

注:ShadowMap与PCF、PCSS 的 Pass 1(从光源视角渲染深度贴图)本质上就是一次"只写深度"的预通道,只是视角换成了光源;本篇 Pass 1 的写法(空片元着色器 + 关颜色写入)与它完全相同。


6. Hi-Z 遮挡剔除

视锥剔除只回答"在不在视锥内",不回答"是否被别的东西挡住"。遮挡剔除(occlusion culling)处理后者:一个在视锥内、但完全被墙挡住的物体,同样不需要绘制。现代的主流方案是 Hi-Z(Hierarchical Z,分层深度缓冲),配合 compute shader 在 GPU 上完成。

6.1 深度金字塔

把深度缓冲构建成金字塔(mipmap 式的层次结构):每一级把上一级 2×2 的块取最大值(最远深度)规约成一个像素。原始深度图是 0 级,逐级减半直到 1×1。

金字塔的意义:第 kk 级的一个像素,代表屏幕上一大块区域(2k×2k2^k \times 2^k 像素)内最远的深度。想知道"某个物体会不会被这块区域挡住",只需在高层做一次查询:物体的最近深度 znearz_{near} 若比该块的最远深度还远(znear>zmax(k)z_{near} > z_{max}^{(k)}),说明它比这块区域里所有已渲染表面都远——一定被挡住,可以剔除。

一个物体在屏幕上覆盖若干像素,把它投影到金字塔合适的层级(覆盖多大区域就查多粗的层级),做一次或几次比较就能得出结论,成本与屏幕覆盖面积成对数关系。

6.2 GPU 驱动的剔除流程

Hi-Z 剔除的完整流程(GPU driven pipeline 的核心一环):

  1. 上一帧渲染完成后,用 compute shader 对深度缓冲逐级规约,构建 Hi-Z 金字塔(见 ComputeShader);
  2. 剔除 pass:对每个物体(实例)计算屏幕包围盒与最近深度,查询 Hi-Z 比较,输出"可见实例列表",通过 indirect draw 命令提交绘制;
  3. 本帧只真正绘制可见的实例。

两个工程要点:

  • 用上一帧的深度有滞后:快速转镜头或物体突然移动时,旧深度会把新出现的物体"错杀"。保守做法是把"上一帧可见"或"刚进入视锥"的物体强制保留,剔除只对连续可见性判断稳定生效;
  • 剔除计算本身在 GPU 上并行执行,物体数上万时依然廉价,这正是 CPU 逐物体查询做不到的。

7. 其他硬件剔除

管线里还有几个不太显眼、但硬件一直在做的剔除:

技术 位置 内容
裁剪(clipping) 图元装配后 完全在视锥外的三角形丢弃,跨越边界的裁成新三角形(见 着色与渲染管线)
小三角形剔除 光栅化前 三个顶点落在同一像素内的三角形直接丢弃,连光栅化都不做
零面积剔除 光栅化前 退化三角形(共线顶点)丢弃

8. 总结:一帧里的层层过滤

技术 阶段 粒度 省下什么
视锥剔除 CPU 应用层 物体 / 实例 draw call 与整条管线
Hi-Z 遮挡剔除 GPU compute 物体 / 实例 draw call 与整条管线
裁剪 图形管线 三角形 光栅化及之后
背面剔除 图形管线 三角形 光栅化及之后
小三角形 / 零面积剔除 光栅化 三角形 光栅化
Early-Z 片元着色前 片元 片元着色
Z-Prepass 应用层策略 片元 片元着色(配合 Early-Z)

几个贯穿全篇的原则:

  1. 越早剔除越划算——最好的优化是不画;
  2. 剔除必须保守——只扔确定看不见的,模棱两可的一律保留;
  3. 各层技术正交组合——视锥剔除、遮挡剔除、背面剔除、Early-Z 各管一段,互不替代。