Chapter 19: Clipping and Culling
一帧场景进入光栅化前,渲染器需要先回答一个朴素问题:哪些几何会影响当前相机看到的图像。这个问题如果放到城市街区、森林、室内关卡或粒子场里,结果会直接改变 draw call 数量、顶点处理量、深度写入量和 fragment shader 工作量。
本章围绕一个贯穿帧展开:相机位于街道中段,场景里有建筑、路灯、车辆、室内房间和远处高楼。渲染器手里有每个物体的包围盒、每个 mesh 的三角形、上一帧深度金字塔以及一套可选的 occlusion query。读完本章后,读者应能定位一个物体被提交、被剔除、被裁剪或被深度遮挡的阶段,并能根据证据判断裁剪策略是否真正降低了帧成本。
Clipping 与 culling 处在同一条可见性链路里,但它们作用层级不同。Culling 通常在物体、mesh、cluster、cell 或 draw 层级提前舍弃一批工作;clipping 则在几何经过投影后,对落在裁剪体边界外的顶点或图元进行边界处理。稳定的渲染器会把二者组合起来:先用粗粒度 culling 减少提交,再让固定管线和局部算法处理边界几何。
本章的最终判断是:裁剪收益来自“提前停止无效工作”,成本来自“额外判断、同步、保守边界和延迟结果”。一次 culling 方案是否成立,需要同时看管线阶段、资源路径和工具证据,不能只看某个算法名字。
19.1 视锥体裁剪的数学基础
视锥体裁剪回答的问题是:一个点、一个包围体或一个三角形在当前相机的可见体积内吗。相机经过 view matrix 和 projection matrix 后,会把世界空间中的点变成 clip space 坐标。clip space 的坐标通常写作 ,其中 参与透视除法。一个顶点通过裁剪测试后,才会继续进入 NDC、viewport transform 和后续光栅化。
在我们的街道帧中,一辆车的世界坐标顶点先乘以 model matrix 得到世界空间位置,再乘以 view-projection matrix 得到 clip space 位置。对于 x 和 y 方向,典型裁剪条件是 与 。z 方向需要绑定 API 语境:OpenGL 风格常用 ;Vulkan、Direct3D 和 Metal 风格通常使用 的深度范围。投影矩阵构造、reverse-Z、near/far 设置和平台约定会改变深度分布,所以工程里应把“clip 条件”和“深度缓冲精度策略”分开检查。
对单个 mesh 的每个三角形做精确 clip 测试成本较高。引擎通常先用包围体做粗粒度判断。包围球只需要中心点到平面的有符号距离和半径;轴对齐包围盒(AABB)需要选择每个平面的最远点或最近点;有向包围盒(OBB)能更贴合旋转物体,但测试成本更高。包围体测试给出的结果通常分为三类:完全在内、完全在外、相交边界。完全在外的对象可以在提交前舍弃,相交对象继续提交给后续 clipping 或更细粒度测试。
视锥体可以表示成六个平面:left、right、bottom、top、near、far。每个平面可写成 ,法线朝向可见空间内部。点到平面的有符号距离为 。当一个包围体位于任意一个平面的外侧时,它对当前视锥没有贡献。这个测试可在 world space 执行,也可在 view space 执行;关键是平面、包围体和矩阵必须处在同一坐标空间。
下面的简化代码展示 AABB 对六个视锥平面的保守测试。它的目的不是精确裁剪三角形,而是给 CPU 或 compute pass 一个快速“提交或舍弃”的判断。
struct Plane {
Vec3 normal;
float distance;
};
struct Aabb {
Vec3 minPoint;
Vec3 maxPoint;
};
bool isOutsideFrustum(const Aabb& bounds, const Plane frustumPlanes[6]) {
for (int planeIndex = 0; planeIndex < 6; ++planeIndex) {
const Plane& plane = frustumPlanes[planeIndex];
Vec3 positiveVertex = bounds.minPoint;
if (plane.normal.x >= 0.0f) positiveVertex.x = bounds.maxPoint.x;
if (plane.normal.y >= 0.0f) positiveVertex.y = bounds.maxPoint.y;
if (plane.normal.z >= 0.0f) positiveVertex.z = bounds.maxPoint.z;
float signedDistance = dot(plane.normal, positiveVertex) + plane.distance;
if (signedDistance < 0.0f) {
return true;
}
}
return false;
}
这段代码选择的是包围盒在平面法线方向上的最远点。若这个最远点仍在外侧,整个 AABB 位于该平面外侧。若最远点在内侧,盒子可能完全在内,也可能穿过平面,此时继续保留对象。这个测试的结果偏保守,保守意味着它会保留一部分边界对象,让后续阶段处理它们;这种设计用少量多余提交换取稳定正确性。
视锥体裁剪的常见错误来自坐标空间混用。比如平面从 view-projection matrix 提取后仍在 world space 语义下使用,包围盒却是 local space;或者物体经过非均匀缩放后仍使用未更新的包围球半径。错误结果通常表现为物体在视野边缘突然消失、near plane 附近闪烁、远处对象提前丢失。排查顺序是固定的:先确认矩阵和包围体空间一致,再确认平面法线方向,再把每个平面的距离值可视化或输出到调试颜色。
19.2 几何剔除与遮挡剔除策略
几何剔除与遮挡剔除共同服务于同一个目标:在图元进入昂贵阶段前减少无效工作。几何剔除依赖相机、物体朝向、分区结构和包围体;遮挡剔除依赖深度信息、遮挡物和时间延迟。二者的输入不同,适用场景也不同。
frustum culling 是最基础的几何剔除。它只判断对象是否落在相机视锥外。街道帧中,相机背后的建筑、左侧街区外的大楼、远 far plane 之后的车辆都可以被视锥测试剔除。它的优势是输入稳定,只需要相机矩阵和对象 bounds;它的边界是看不到“被前方建筑遮住”的关系,所以远处楼群即使被近处墙面挡住,也会通过视锥测试。
backface culling 工作在三角形朝向层面。一个闭合实体的背向三角形对外部视角没有可见贡献,GPU 可以根据 winding order 和 front face 状态舍弃它们。这个判断通常发生在固定管线中,能减少进入后续片元处理的三角形。它依赖模型面朝向一致、负缩放处理正确、双面材质状态明确。树叶、布料、头发卡片和透明薄片经常需要双面渲染,此时 backface culling 的收益和视觉需求需要一起判断。
occlusion culling 关心的是遮挡关系。街道帧中,近处整排建筑已经写入深度,建筑背后的车辆和路灯即使在视锥内,也不会出现在最终图像中。遮挡剔除通常使用 occluder、深度缓冲、Hi-Z buffer、上一帧可见性或硬件 query 来估计对象是否被挡住。它能减少 vertex shader、rasterization 和 fragment shader 工作量,但会引入额外 pass、深度资源读取、结果延迟和保守误差。
portal 和 cell culling 常用于室内、地牢、房间连接或城市街区分区。cell 表示一个空间区域,portal 表示区域之间的可见开口。相机所在 cell 决定初始可见集,portal 再限制能看到的相邻 cell。这个方法在室内场景很有效,因为墙体天然形成遮挡边界;在开放世界里,portal 数量和空间连通性会增加维护成本。
cluster culling 把 mesh 或场景拆成更小的 cluster,例如 meshlet、tile、sector 或 GPU cluster。每个 cluster 有自己的 bounds、cone、material range 或 LOD 信息。GPU compute pass 可以并行测试这些 cluster,并生成 indirect draw 参数。它的收益来自更细粒度的舍弃;它的成本来自 buffer 构建、prefix sum、compaction、barrier 和 indirect draw 管线组织。
这条可见性链路可以概括为下面的帧内路径。图中只展示典型顺序,实际引擎会根据场景类型和 API 能力调整顺序。
这条路径的关键点是分层。bounds update 解决物体当前空间范围;frustum culling 解决相机外对象;partition visibility 解决大区域可见集;occlusion test 解决被深度遮挡的对象;draw list 才进入 API 提交和 GPU 管线。若把所有对象直接交给 GPU,再依赖深度测试压掉不可见片元,顶点阶段和部分光栅化工作已经发生,CPU 提交成本也已经付出。
各种剔除方法之间没有固定优先级,工程顺序由成本模型决定。对象数量极多且 bounds 更新便宜时,CPU frustum culling 常放在前面。遮挡物稳定且场景有明显墙体时,occlusion culling 能带来稳定收益。mesh 很大且内部遮挡明显时,cluster culling 比整 mesh 剔除更有效。透明物体、alpha test 植被和动态小物件通常需要更保守的策略,因为它们的遮挡结果和视觉排序更敏感。
19.3 视锥裁剪算法与实现细节
视锥裁剪的实现首先要确定执行位置。CPU 层级裁剪适合对象级、节点级和场景分区级判断;GPU compute culling 适合大量实例、cluster 和 indirect draw;固定管线 clipping 适合三角形穿过裁剪边界后的局部处理。把这三层混在一起会导致调试困难,因为同一个物体可能在不同阶段被保留、拆分或舍弃。
CPU 层级裁剪通常绑定场景树。每个节点保存 children bounds 和局部变换,渲染前从根节点开始测试。若一个节点的 bounds 完全在视锥外,整个子树都可以舍弃;若节点完全在内,子节点可以减少平面测试;若节点与边界相交,再继续访问子节点。BVH、octree、loose octree、quadtree 和 scene graph 都可以承载这类逻辑。选择结构时,核心判断是场景更新频率、对象分布和查询粒度。
GPU compute culling 把可见性判断搬到 GPU。实例数据、bounds、view-projection matrix 和可选的 Hi-Z texture 作为输入;compute shader 并行测试每个实例或 cluster;通过 append buffer、prefix sum 或 compaction 生成可见索引;随后通过 indirect draw 读取可见列表。这个路径能降低 CPU 遍历和提交压力,适合实例数量很大的场景。它的边界在同步和资源状态:culling pass 写出的 buffer 必须在后续 draw indirect 前完成可见性写入,API 需要明确 barrier 或等价同步。
Hi-Z buffer 是遮挡剔除里的常见数据结构。它把深度缓冲构造成 mip chain,每一层保存一个区域的保守深度值。测试对象时,引擎把对象 bounds 投影到屏幕矩形,根据矩形大小选择 mip 层,再比较对象最近深度和该区域已有深度。若对象的最近深度仍被已有深度挡住,可以把对象判为遮挡。这个方法把像素级深度关系压缩成区域级测试,适合 GPU 并行执行。
Hi-Z 的保守性取决于深度定义和 mip 聚合方式。普通 Z 和 reverse-Z 的“近”和“远”数值方向不同,mip 中应该保存 min depth 还是 max depth 必须和比较函数一致。多采样深度、透明物体、alpha test 和 late depth 行为也会影响可用遮挡信息。工程实现中应明确:哪些 pass 写入遮挡深度,哪些物体可以作为 occluder,哪些对象需要跳过遮挡剔除。
下面的伪代码展示 compute culling 的最小路径。它故意省略 prefix sum 细节,只表达输入、测试和输出之间的关系。
// GLSL-like pseudocode for one instance per invocation.
void main() {
uint instanceId = gl_GlobalInvocationID.x;
InstanceBounds bounds = boundsBuffer[instanceId];
bool visibleByFrustum = testFrustum(bounds, cameraPlanes);
if (!visibleByFrustum) {
visibilityBuffer[instanceId] = 0u;
return;
}
ScreenRect rect = projectBoundsToScreen(bounds, viewProjectionMatrix);
bool hiddenByDepth = testHiZ(rect, bounds.nearestDepth, hizTexture);
visibilityBuffer[instanceId] = hiddenByDepth ? 0u : 1u;
}
这个 pass 的输出不是最终图像,而是后续 draw list 的输入。visibilityBuffer 可以被 prefix sum 压缩成 visible instance list,也可以直接参与 indirect argument 构建。调试这类 pass 时,最有效的观察不是只看最终帧,而是把每个对象的可见状态画成颜色:视锥外对象、Hi-Z 遮挡对象、保留对象分别用不同颜色显示,再和相机移动过程对照。
固定管线 clipping 处理的是图元边界问题。一个三角形可能有一个顶点在视锥内、两个顶点在视锥外。管线不能直接丢弃整个三角形,因为它与视锥体相交后仍可能覆盖屏幕像素。clipping 会沿裁剪平面插入新顶点并保留内部多边形。现代 GPU 的具体实现受 API 和硬件影响,应用层通常把它视为固定管线行为;应用层优化重点是让大量完全外侧对象在此之前被 coarse culling 处理掉。
conservative test 是可见性系统的安全边界。对象 bounds 可以放大一点,screen rect 可以向外扩张一点,Hi-Z 选择可以偏向保留对象。这样做会增加少量多余绘制,但能减少视野边缘闪烁和遮挡误判。可见性系统通常优先选择“保留可疑对象”,因为少画一个实际可见对象会形成明显视觉错误,多画一个被遮挡对象主要影响性能。
19.4 Occlusion Query 与分区剔除
Occlusion Query 是由 GPU 统计某段绘制通过深度和模板测试的 sample 数量,再把结果提供给应用层的机制。它能回答“这个 bounds proxy 或对象在当前深度条件下是否产生可见样本”。在图形 API 中,query 通常需要 begin、draw proxy、end、读取或复制结果。结果读取如果发生在同一帧关键路径上,就会把 CPU 或后续 GPU 工作卡在等待点上。
街道帧中可以用简单盒子作为大型建筑、车辆组或房间入口的 proxy。渲染器先绘制主深度或上一帧深度,再对 proxy 发出 query。若 query 结果为零,下一帧可以跳过对应对象组;若结果大于零,则提交该组真实 mesh。这个方案把 query 结果延后一帧使用,降低同步等待。延迟使用带来一个边界:相机快速移动时,上一帧不可见的对象可能在当前帧变成可见,所以系统需要保守保留策略、相机切换重置或可见性缓冲过期规则。
query 粒度需要控制。给每个小物件发 query 会制造大量命令、状态切换和结果管理成本;给整个街区发一个 query 又会过粗,任何一点可见都会保留大量对象。较稳妥的粒度是分区节点、大 occluder 背后的对象组、房间 cell、城市 block 或 mesh cluster 组。query 的收益来自减少后续真实 draw,成本来自 proxy draw、query 管理和结果延迟。
分区剔除给 query 提供结构。BVH 适合不规则静态场景和 mesh 组;octree 适合空间分布较均匀的对象;quadtree 常用于地形和城市平面;cell/portal 适合房间连接清晰的室内结构。分区节点的 bounds 先做 frustum test,再按需要做 occlusion test。若父节点被遮挡,子节点都可以跳过;若父节点可见,再访问子节点并提高精度。
visible set cache 可以减少抖动和重复判断。引擎为每个分区记录最近几帧的可见状态、query 结果时间戳、相机区域和置信度。相机小幅移动时,可以沿用上一帧可见集并做增量更新。相机瞬移、开门、破坏墙体、场景 streaming 或 occluder 变化时,缓存需要失效或降级为保守保留。这个缓存策略把 query 从“每帧全量决定”变成“持续维护可见集”。
下面的顺序适合大多数带 query 的分区剔除系统。先让低成本测试过滤大部分对象,再把 query 用在高收益区域。
- 更新 bounds:把动态物体、骨骼代理和分区节点更新到当前世界空间。
- 执行 frustum test:移除视锥外节点,并记录完全在内与边界相交状态。
- 选择 query 候选:只为对象数量多、遮挡概率高、proxy 成本低的节点发 query。
- 使用延迟结果:读取前几帧 query 结果,当前帧按保守规则提交或跳过节点。
- 维护可见集:记录相机变化、节点状态和缓存有效期,快速移动时扩大保留范围。
Occlusion Query 的调试要同时观察三类证据。第一类是 query 数量和等待时间,说明机制本身的 CPU/GPU 成本。第二类是被跳过的真实 draw 数量和顶点数量,说明它节省了多少后续工作。第三类是可见性错误或 popping,说明延迟结果和 bounds 是否过于激进。三类证据需要一起看;只有 query 数量下降,不能证明帧性能改善。
19.5 性能观察:裁剪对渲染性能的影响
裁剪和剔除优化的收益要回到帧成本。一个对象被舍弃后,可能减少 CPU draw list 构建、API 命令提交、vertex shader 执行、primitive assembly、rasterization、fragment shader、depth test、color write 和带宽访问。具体减少哪一部分,取决于它原本会走到管线的哪个阶段,以及该帧的瓶颈在哪里。
在街道帧中,如果瓶颈是 CPU 提交,frustum culling 和分区剔除能通过减少 draw call 和 command buffer 内容改善帧时间。如果瓶颈是顶点处理,LOD、meshlet culling 和 cluster culling 更可能产生收益。如果瓶颈是 overdraw 或 fragment shader,depth pre-pass、Hi-Z occlusion 和 front-to-back 排序更直接。如果瓶颈是 query 等待或 buffer compaction,增加剔除算法可能让帧更慢。
性能观察应使用同一组指标前后对比。提交数量看 draw call、instance count、indirect argument count 和 command buffer 大小。顶点工作看 vertex invocation、primitive count、post-transform cache 命中和 mesh shader task 数量。像素工作看 overdraw heatmap、fragment invocation、depth pass 成本和 color attachment bandwidth。同步成本看 query result wait、barrier、compute-to-graphics dependency 和 CPU frame pacing。
裁剪方案的失败常见于三种情况。第一种是 bounds 过大,导致对象总是通过测试;第二种是对象太小或太少,测试成本超过节省的绘制成本;第三种是结果读取太早,query 或 compute culling 变成同步点。对应的排查顺序是:先确认被剔除对象数量和真实 draw 减少量,再确认节省的管线阶段是否位于当前瓶颈,最后检查 culling pass、query 和 barrier 自身成本。
一个实用的性能复盘表可以这样组织。表中的“证据”来自 profiler、frame capture 或引擎统计,目的在于把视觉可见性判断转成帧时间判断。
| 观察对象 | 需要看的证据 | 能回答的问题 |
|---|---|---|
| CPU 提交 | draw call 数量、command buffer 构建时间、visible list 大小 | 剔除是否减少了 CPU 侧组织工作 |
| 顶点阶段 | vertex invocation、primitive count、meshlet 数量 | 剔除是否减少了几何处理量 |
| 像素阶段 | overdraw、fragment invocation、depth pass 时间 | 遮挡剔除是否减少了片元工作 |
| 同步路径 | query wait、barrier 时间、GPU bubble | 可见性系统是否引入等待 |
| 视觉稳定性 | popping、边缘消失、相机快速移动错误 | 保守边界是否足够稳定 |
性能结论要按“现象 → 阶段 → 证据 → 修改”闭合。例如帧率下降的现象发生在开启 occlusion query 后;profiler 显示 CPU 等待 query result;frame capture 显示真实 draw 减少有限;结论是 query 粒度太细且结果读取过早;修改方向是延迟一帧读取、合并 query 节点或改用 GPU Hi-Z compute culling。这个结论同时解释了慢在哪里、为什么慢、下一步改哪里。
裁剪最终是一套风险控制系统。它用保守数学判断保护画面正确,用层级结构降低工作规模,用深度信息减少遮挡区域的无效渲染,用工具证据验证收益。读者在分析自己的引擎时,应先把对象放到“视锥外、背向、被遮挡、边界相交、真实可见”这五类里,再判断每一类由哪个阶段处理、对应成本是否值得付出。
最小自检任务
给定一个室内场景:相机站在走廊入口,左侧有三个房间,右侧有一堵长墙。每个房间是一个 cell,门洞是 portal;场景里每个家具都有 AABB;引擎已经有上一帧 depth buffer 生成的 Hi-Z texture。请设计一条当前帧可见性判断顺序,用来决定哪些家具进入 draw list。要求说明每一步使用的输入、作用阶段、可能产生的错误和调试证据。
答案要点
先更新每个家具和 cell 的世界空间 bounds,保证 AABB、portal 平面和相机矩阵处在同一坐标空间。接着从相机所在 cell 开始做 cell/portal 遍历,只把通过门洞可见的房间加入候选集;这一步使用空间分区,减少后续对象数量。然后对候选家具做 frustum culling,视锥外家具直接从 draw list 候选中移除,边界相交家具保留给后续阶段。再把保留下来的家具 bounds 投影到屏幕矩形,使用 Hi-Z texture 做保守 occlusion test;被墙体或前景家具遮挡的对象可以跳过,测试不确定时保留。最后生成 draw list 或 indirect arguments,并记录每类对象数量。
关键错误包括 bounds 未随动画更新、portal 方向或门洞裁剪范围错误、Hi-Z 深度比较方向和 reverse-Z 设置不一致、上一帧遮挡结果在相机快速移动时过期。调试证据应包括:每个 cell 的可见状态颜色、frustum 外对象标记、Hi-Z 遮挡对象标记、最终 draw list 数量、vertex invocation 变化和 query 或 compute pass 时间。正确方案的核心不是让剔除数量最大,而是让可见性错误受控,并让减少的 draw、顶点或片元工作超过 culling 自身成本。
本章知识点总结
- 可见性链路:裁剪和剔除共同决定对象是否进入后续管线,收益来自提前停止无效工作。
- Clip Space:顶点经过 view-projection 后进入 clip space,x、y、z 与 w 的关系决定基础裁剪边界。
- 深度约定:OpenGL、Vulkan、Direct3D 和 Metal 的 z 裁剪范围存在差异,工程检查必须绑定 API 语境。
- 包围体测试:AABB、OBB 和包围球用保守几何判断换取低成本可见性过滤。
- 空间一致:视锥平面、bounds 和矩阵必须处在同一坐标空间,空间混用会造成边缘消失和闪烁。
- 几何剔除:frustum、backface、portal 和 cluster culling 分别作用在对象、三角形、空间区域和细粒度几何组。
- 遮挡剔除:occlusion culling 依赖深度信息或遮挡结构,适合减少被前景遮住的无效绘制。
- GPU Culling:compute culling 使用 buffer、Hi-Z、compaction 和 indirect draw 构建 GPU 侧可见列表。
- Hi-Z Buffer:深度金字塔把像素级遮挡关系压缩成区域级测试,深度方向必须和比较函数一致。
- Query 延迟:Occlusion Query 结果适合延后一帧使用,同帧读取容易形成等待点。
- 分区结构:BVH、octree、quadtree 和 cell/portal 为大场景提供层级可见性入口。
- 保守策略:可疑对象优先保留,少量多余绘制通常比可见对象丢失更稳定。
- 性能证据:裁剪收益需要通过 draw call、vertex invocation、overdraw、barrier 和 query wait 一起判断。
- 复盘顺序:先看剔除数量,再看减少的管线阶段,最后检查 culling pass 自身成本。