剔除总结
需要预先学习:
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 视锥体的六个平面
视锥体由六个平面围成:左、右、上、下、近平面、远平面。平面用平面方程表示:
点 代入 ,符号说明它在平面的哪一侧(视锥内部为正, 取指向锥内的方向)。
这六个平面不需要单独构造——直接从组合矩阵 (投影乘视图)的行向量线性组合提取,这是经典的 Gribb-Hartmann 方法(行向量约定下):
| 平面 | 提取式 |
|---|---|
| 左平面 | |
| 右平面 | |
| 下平面 | |
| 上平面 | |
| 近平面 | |
| 远平面 |
每个提取出的四维向量 就是一个平面方程(使用前通常归一化,让 ,方便后面按距离比较)。
2.2 包围体:球与 AABB
逐三角形测试太贵,视锥剔除针对的是物体整体。给每个物体预先算一个粗糙的包围体(bounding volume):
- 包围球(bounding sphere):中心 、半径 。测试最简单:点到平面的距离 ,若小于 ,球整体在平面外侧:
// 包围球与平面的测试:在外侧则整个球不可见
bool sphereOutside(const Plane& p, const Sphere& s)
{
return dot(p.n, s.center) + p.d < -s.radius;
}
- AABB(Axis-Aligned Bounding Box,轴对齐包围盒):区间 之间的长方体,对细长物体的贴合度通常好于包围球。
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;
道理很直接:盒子里所有点在法线方向上的投影都不超过正顶点,正顶点都在平面外侧()时,其他点更在外面。测试结果三态:
| 正顶点代入值 | 结论 | 处理 |
|---|---|---|
| 盒子整体在平面外侧 | 剔除,直接跳过后续平面 | |
| 且反顶点 | 相交 | 保守当作可见 |
| 反顶点也 | 盒子整体在锥内 | 可见,且剩余平面只需测正顶点一侧 |
六个平面都通过(都不是"整体在外"),物体才可能可见。
注意:剔除必须宁可错放,不可错杀——把有一丝可见可能的物体剔掉,画面就会"凭空消失"。AABB 比物体本身大、相交情况当作可见,这些"保守"都是正确行为,付出的只是多画几个物体的代价。
2.4 层次化剔除
场景里物体成千上万时逐个测试六个平面也不便宜。用层次包围结构(BVH、八叉树、场景图)把物体组织成树,每个节点有自己的包围体:节点整体在外侧,整棵子树一次跳过,测试次数从 降到 。
3. 背面剔除(Back-face Culling)
封闭网格中大约一半的三角形背对摄像机(比如立方体的背面三个面)。背面剔除(back-face culling)把这些三角形在光栅化之前直接丢弃,省掉它们的全部光栅化与着色。
3.1 原理:环绕顺序
三角形投影到屏幕后,顶点的环绕顺序(winding order,顺时针或逆时针)暴露了它朝向哪边:正面三角形投影后顶点逆时针(以 OpenGL 默认约定),背面三角形顺时针。判断依据与 光栅化 中"点是否在三角形内"的叉积同源——三角形面积的有向二倍:
符号为负说明环绕方向翻转了,是背面。
3.2 OpenGL 中的用法
glEnable(GL_CULL_FACE); // 默认是关闭的
glCullFace(GL_BACK); // 剔除背面(默认值)
glFrontFace(GL_CCW); // 逆时针为正面(默认值)
两个常见的坑:
- 建模时环绕顺序必须一致。模型导出、索引重排、面片拼接中只要有一批三角形顶点顺序反了,那批面就会"消失"或"内外翻转";
- 镜像缩放会翻转环绕。对物体用了负的缩放矩阵(镜像),原来逆时针的投影变顺时针,所有面内外翻转——经典 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。
金字塔的意义:第 级的一个像素,代表屏幕上一大块区域( 像素)内最远的深度。想知道"某个物体会不会被这块区域挡住",只需在高层做一次查询:物体的最近深度 若比该块的最远深度还远(),说明它比这块区域里所有已渲染表面都远——一定被挡住,可以剔除。
一个物体在屏幕上覆盖若干像素,把它投影到金字塔合适的层级(覆盖多大区域就查多粗的层级),做一次或几次比较就能得出结论,成本与屏幕覆盖面积成对数关系。
6.2 GPU 驱动的剔除流程
Hi-Z 剔除的完整流程(GPU driven pipeline 的核心一环):
- 上一帧渲染完成后,用 compute shader 对深度缓冲逐级规约,构建 Hi-Z 金字塔(见 ComputeShader);
- 剔除 pass:对每个物体(实例)计算屏幕包围盒与最近深度,查询 Hi-Z 比较,输出"可见实例列表",通过 indirect draw 命令提交绘制;
- 本帧只真正绘制可见的实例。
两个工程要点:
- 用上一帧的深度有滞后:快速转镜头或物体突然移动时,旧深度会把新出现的物体"错杀"。保守做法是把"上一帧可见"或"刚进入视锥"的物体强制保留,剔除只对连续可见性判断稳定生效;
- 剔除计算本身在 GPU 上并行执行,物体数上万时依然廉价,这正是 CPU 逐物体查询做不到的。
7. 其他硬件剔除
管线里还有几个不太显眼、但硬件一直在做的剔除:
| 技术 | 位置 | 内容 |
|---|---|---|
| 裁剪(clipping) | 图元装配后 | 完全在视锥外的三角形丢弃,跨越边界的裁成新三角形(见 着色与渲染管线) |
| 小三角形剔除 | 光栅化前 | 三个顶点落在同一像素内的三角形直接丢弃,连光栅化都不做 |
| 零面积剔除 | 光栅化前 | 退化三角形(共线顶点)丢弃 |
8. 总结:一帧里的层层过滤
| 技术 | 阶段 | 粒度 | 省下什么 |
|---|---|---|---|
| 视锥剔除 | CPU 应用层 | 物体 / 实例 | draw call 与整条管线 |
| Hi-Z 遮挡剔除 | GPU compute | 物体 / 实例 | draw call 与整条管线 |
| 裁剪 | 图形管线 | 三角形 | 光栅化及之后 |
| 背面剔除 | 图形管线 | 三角形 | 光栅化及之后 |
| 小三角形 / 零面积剔除 | 光栅化 | 三角形 | 光栅化 |
| Early-Z | 片元着色前 | 片元 | 片元着色 |
| Z-Prepass | 应用层策略 | 片元 | 片元着色(配合 Early-Z) |
几个贯穿全篇的原则:
- 越早剔除越划算——最好的优化是不画;
- 剔除必须保守——只扔确定看不见的,模棱两可的一律保留;
- 各层技术正交组合——视锥剔除、遮挡剔除、背面剔除、Early-Z 各管一段,互不替代。


