Skip to main content

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 的坐标通常写作 (x,y,z,w)(x, y, z, w),其中 ww 参与透视除法。一个顶点通过裁剪测试后,才会继续进入 NDC、viewport transform 和后续光栅化。

在我们的街道帧中,一辆车的世界坐标顶点先乘以 model matrix 得到世界空间位置,再乘以 view-projection matrix 得到 clip space 位置。对于 x 和 y 方向,典型裁剪条件是 wxw-w \le x \le wwyw-w \le y \le w。z 方向需要绑定 API 语境:OpenGL 风格常用 wzw-w \le z \le w;Vulkan、Direct3D 和 Metal 风格通常使用 0zw0 \le z \le w 的深度范围。投影矩阵构造、reverse-Z、near/far 设置和平台约定会改变深度分布,所以工程里应把“clip 条件”和“深度缓冲精度策略”分开检查。

对单个 mesh 的每个三角形做精确 clip 测试成本较高。引擎通常先用包围体做粗粒度判断。包围球只需要中心点到平面的有符号距离和半径;轴对齐包围盒(AABB)需要选择每个平面的最远点或最近点;有向包围盒(OBB)能更贴合旋转物体,但测试成本更高。包围体测试给出的结果通常分为三类:完全在内、完全在外、相交边界。完全在外的对象可以在提交前舍弃,相交对象继续提交给后续 clipping 或更细粒度测试。

视锥体可以表示成六个平面:left、right、bottom、top、near、far。每个平面可写成 ax+by+cz+d=0ax + by + cz + d = 0,法线朝向可见空间内部。点到平面的有符号距离为 ax+by+cz+da x + b y + c z + d。当一个包围体位于任意一个平面的外侧时,它对当前视锥没有贡献。这个测试可在 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 自身成本。