想象你是一位大型舞台剧导演,剧场里站着三百名演员。演出就要开始,后台却只有五十个化妆位——如果让所有人同时挤进去化妆,后台会乱成一锅粥,而且观众根本看不见黑暗角落里的演员。聪明的导演拿起追光灯,只照亮舞台上真正会演出的那一小块区域;灯光之外,演员安心待机,不用上场。 这就是 3D 渲染里的视锥剔除(Frustum Culling)。GPU 的“追光灯”是相机视锥体,只有落在这束锥形光柱里的物体才值得绘制;屏幕外的物体直接跳过,省下海量的计算开销。为了判断“谁在光里、谁在光外”,导演助理需要一套工具箱:用快递纸箱粗包住演员(AABB 包围盒),用卷尺量盒子与光墙的距离(平面相交判定),再按后台分区批量放行(空间划分加速),最后让团长统一报数(层次包围体)。这篇文章,就是带你从零搭起这套“灯光剧本审查系统”。
理解视锥剔除,是进入 3D 渲染优化的第一块敲门砖。无论做游戏引擎、数字孪生还是空间计算,掌握“如何优雅地跳过看不见的工作”,能让你的渲染性能成倍提升,走出“只会画、不会省”的瓶颈。
目录
可视化演示
通过视锥体几何结构、包围盒类型对比、层次化BVH剔除三个模式,展示视锥剔除如何跳过屏幕外物体节省渲染开销。
本节配有交互动效(进入视口后自动加载,约 3KB)。
视锥体定义与物体可见性判定 视锥体由近远裁剪面和四个侧面围成视锥外物体不可见直接跳过渲染 从投影矩阵提取六个平面用平面方程判定物体包围盒是否在视锥内 ax + by + cz + d = 0 (6 planes) 视锥剔除在渲染管线早期跳过不可见物体避免计算浪费
视锥体定义——追光灯的六面光墙
舞台剧导演手里的追光灯,从灯口射出一束逐渐扩散的锥形光柱。光打到哪里,观众就看到哪里。光柱之外,就算舞台上站满了人,也隐没在黑暗里。这束光柱的边界,可以想象成六面“看不见的光墙”:离灯最近的一面(近墙)、舞台尽头的一面(远墙),以及上下左右四面侧墙。视锥体,就是这六面墙围出的空间。
把镜头切回 3D 渲染:GPU 的“眼睛”是相机,它能看到的区域正是这样一个视锥体。靠近相机的一头叫近裁剪面,视野尽头叫远裁剪面,中间由上下左右四个侧面收拢。视锥体的具体形状由相机参数决定——视场角(FOV)、宽高比(Aspect)、近裁剪距离和远裁剪距离。导演拧一拧灯头,光柱变宽变窄;开发者改一改相机参数,视锥随之变形。
在数学上,每一面“光墙”都被抽象成一个无限延伸的平面。判定一个点在哪一侧,用的是平面的隐式方程:
回到剧场:六面光墙的方程都备好了,导演助理要做的,就是逐个检查演员位于每面墙的哪一侧。这是后续所有剔除逻辑的地基。
- 平面法向量 NN 垂直于平面并指向视锥外侧,它决定了这面墙“朝哪边”,就像光墙上贴着向外指的箭头。
- 点积 N·P把空间任意点 P 的坐标投影到 N 方向上,得到的标量代表“P 沿法线方向走了多远”。
- 常数 D(偏移)整面墙相对原点平移的量,由近远平面距离或视场角计算得出。
- 正负判定结果为正说明点在墙外,为负说明在墙内,为零说明点在墙上——这正是所有剔除判定的起点。
剔除核心思想——只让灯光里的演员上场
三百名演员当然不可能同时挤上舞台。舞台监督翻开灯光剧本,把光墙之外“待机名单”上的演员全部划掉:今日不上台。观众看不到黑暗里的表演,强行让他们上台只会踩脚、挡光、浪费体力。渲染也一样——屏幕外的物体渲染了也没有任何像素贡献,只会拖慢帧率。
做一次“可见性预算”是划算的:渲染一帧的总耗时,可以拆成三块:
在具体实现上,视锥剔除遵循保守原则:只有当物体完全确定在视锥之外时才跳过。凡有一丝可能出现在画面里的物体,都必须保留,否则画面会出现“空洞”——明明该露脸的物体突然消失,那可比性能卡顿更尴尬。正式因为这种“宁可多留、不可错杀”的策略,视锥剔除通常放在渲染管线的 CPU 端,在发送绘制命令之前完成。
- 绘制时间 T_drawGPU 真正执行着色的时间,和提交的物体数量直接相关,是每帧开销的大头。
- 剔除时间 T_cullCPU 在提交前做可见性检测的时间,通常只占绘制时间的很小一部分。
- 其他时间 T_other场景加载、数据传输、后处理等固定开销,剔除对它没有影响。
- 收益逻辑只要剔除省下的绘制时间远大于剔除本身的开销,方案就值得做——花小钱省大钱。
AABB包围盒——快递纸箱粗检测
检查一个演员是否在灯光里,最快的办法不是研究他华丽的裙摆和头饰,而是直接套一个快递纸箱:横平竖直、正好装下整个人。只要纸箱不在灯光范围里,里面的人一定不在。这个纸箱就是 AABB(轴对齐包围盒,Axis-Aligned Bounding Box)——它与坐标轴对齐,棱角方向永远不旋转。
AABB 的数学描述极其简洁,只需要两个对角点(最小值点和最大值点)就能定义。更常用的存储方式是“中心 + 半边长”:
为什么不直接用物体的自身网格?因为一个几万面片的雕像逐顶点测太贵,而用纸箱代替后,测试成本变成固定的几次浮点运算。相比 OBB(有向包围盒)——可以旋转贴合物体的箱子,AABB 虽然装起来更“空”,但检测便宜得多。在视锥剔除这个场景里,多保留几个边缘物体完全可接受,省下的计算时间才是硬道理。
- min 与 max盒子在 X、Y、Z 三个轴上的最小角和最大角坐标,对应快递箱的“左下后角”和“右上前角”。
- 中心点 C两个对角角取平均,得到盒子的几何中心,相当于快递箱的重心位置。
- 半边长 r最大值减最小值再除以 2,得到从中心到各面的半个边长。注意,后续平面测试里它扮演“盒子的等效半径”。
平面相交判定——卷尺量箱子的胖瘦
助理拿着卷尺站在每面光墙前,只需要两个读数:纸箱中心离墙有多远?纸箱沿墙的“投影宽度”有多宽?如果中心离墙的距离比纸箱投影还宽,说明整个箱子都在墙的背光面——这个纸箱和灯光彻底无缘。
数学实现用的是 p-vertex 法(正向顶点法):把盒子的三个半边长分别投影到平面法向量方向上,加总成一个“投影半径”,再与中心到平面的有符号距离比较:
注意,盒子必须逐一通过全部六个平面的测试,只要任何一个平面判定“整个盒子在外侧”,盒子就直接剔除,后面的平面不用再测。实际工程中,平面遍历顺序也会影响性能:如果把近平面放最前,几乎所有位于相机后方的物体第一轮就被刷掉,后续五个平面根本不用执行。
- 有符号距离 s把盒子中心代入平面方程,得到中心沿法线方向走到平面外侧的净距离。法向量统一指向视锥外侧,所以 s 为正表示中心在外侧。
- 投影半径 r_p把盒子三个半边长各自投影到法线方向上再相加,等价于从法线方向看过去,盒子的“等效轮廓宽度”。法向量与某个轴夹角越小,该轴的半边长权重就越大。
- 全外判定中心的距离已经超过盒子的最大轮廓,说明盒子上没有一个点能回到平面内侧。这个平面宣布“盒子在外面”。
空间划分加速——后台分区管理
后台有三百名演员,如果助理挨个问“你在光里吗”,虽然每次很快,但次数太多。更好的办法是看后台地图:剧场后台按区域划分成 A 化妆间、B 化妆间、C 道具区……如果整个 A 区都不在灯光范围内,那么 A 区里的八十名演员全部跳过——一次判定,放行八十人。
这就是空间划分加速。在场景加载阶段,预先将 3D 空间切分成若干块(均匀网格、四叉树、八叉树、BSP 树等),剔除时不再逐个检测物体,而是逐块检测:先测“这个空间块在不在视锥里”,不在就整块跳过。以应用最广的八叉树为例,它把场景包围盒递归切成 8 个相等的子立方体,直到达到预设深度。剔除时从根节点开始,逐层剪枝:
空间划分对静态场景(建筑、地形、植被)尤其有效,因为树结构可以离线构建、长期复用。动态角色如果也塞进八叉树,移动后需要高频更新节点,反而拖慢性能,通常单独走层次包围体或独立列表管理。
- 第 0 层(根节点)根节点代表整个场景,无论视锥指向哪里都要先测它,所以贡献 1 次访问。
- 第 1 层根节点的 8 个子节点,各自只有 1-p 的概率需要深入检查(其余概率被整块剔除),期望访问 8(1-p) 个。
- 第 2 层再往下一层有 64 个更小的子块,它们能被访问的前提是上面两层都没被剔除,概率为 (1-p)²,贡献 64(1-p)² 次访问。
- 剪枝效果当 p = 0.75 时,E ≈ 1 + 2 + 4 = 7,即平均只访问约 7 个节点就能完成整棵树的判定,而树可能包含上百个节点——这就是分块跳过的力量。
层次包围体——团长统一调度
话剧团有三十名演员散落在舞台右侧。助理如果一个个问,太慢。他直接走到团长面前:“你们团整体在光墙范围内吗?”团长扫了一眼排练表:不在。于是三十个人的检测,被团长一个人代表省略了。这就是层次包围体(BVH)的直觉——用一棵“组织的树”来管理物体。
BVH 不分割空间,而是将物体按空间邻近关系递归分组。根节点是一个包住全部物体的粗包围盒,往下每个节点包住它子树里的所有物体。剔除时自顶向下遍历:先测父节点,父节点整个在视锥外,则它的所有后代节点全部跳过;父节点可见,才继续测它的子节点。这种“祖孙联动”让节省的成本可以量化:
BVH 的另一个优势是动态适应性:物体移动后,只更新它所在的那条树链,比整体重建八叉树便宜得多。因此现代引擎的常见组合是:静态场景用空间划分(八叉树/网格),动态物体用 BVH 或独立列表,两道关卡协同过滤。
- 单次测试成本 c_test一次“盒子 vs 六个平面”的检测,通常是几十次浮点运算,微秒级成本。
- 叶子数量 n_leaf被整棵跳过的子树中包含的叶子物体数。一个满载的合唱团可以是 30 人,一个城市区块可以是上千栋建筑。
- 净节省减去父节点那次必做的检测,剩下的就是纯利。n_leaf 越大,层次剔除的威力越明显。
工程实践要点——灯光调试实战
灯光师每次彩排后都会看监视器回放:这束光到底覆盖了舞台多少面积?多少演员压根没进过光?如果一场戏只有两成演员真正上了台,说明灯光太散,电费(功耗)和精力(带宽)都浪费了。渲染引擎同样需要一个“剔除率仪表盘”来评估视锥剔除的健康度:
工程落地上还有三个高频要点。第一,静态合并:把相邻的小物体(碎石、桌凳)批处理成一个整体再做剔除,既减少检测次数又减少绘制调用;第二,与 LOD 配合:视锥剔除只是第一道关卡,屏幕内的物体还要靠层次细节、遮挡剔除进一步压缩;第三,CPU 与 GPU 协同:现代引擎(如 GPU-driven rendering)把剔除逻辑搬到 Compute Shader 中并行执行,避免 CPU 提交瓶颈。好的灯光师不会把全场照亮,好的渲染引擎也不会渲染看不见的物体。
- 总物体数 N_total场景中所有送入剔除检测的物体数,相当于当晚全体待命演员名单。
- 被剔除数 N_culled被判定完全不可见、跳过渲染的物体数,相当于被划掉“待机”的演员。
- 比值含义0 到 1 之间的比率。配合调试视图把被剔除物体染成半透明灰色,开发者一眼就能看出场景树的哪些分支“白占资源”。
AI 应用场景
开放世界游戏的场景剔除
阶段:游戏引擎渲染管线优化(应用阶段剔除)
解决的问题:开放世界场景中,单帧可见范围内可能有数万栋建筑、树木和岩石。传统“全量提交、GPU 硬扛”的方式会让 Draw Call 爆炸,帧率跌破可玩线。视锥剔除把屏幕外物体提前拦下,是解决这个问题的第一道防线。
具体案例:在某开放世界游戏原型中,场景包含 50,000 个静态物体。用八叉树做空间划分、AABB 做平面测试后,每帧送入渲染队列的物体稳定在 4,000 到 6,000 个,剔除率超过 90%,Draw Call 从 5 万降到 4 千以下,帧时间从 38ms 降到 7ms。
数字孪生城市可视化
阶段:大规模 3D 数据可视化渲染优化
解决的问题:城市级三维地图包含数亿个三角面片,浏览器端显存和 GPU 算力都极为有限。视锥剔除让前端只加载和渲染视野范围内的模型块,保证城市漫游的流畅度。
具体案例:某智慧城市项目中,把城市模型按区块构建四叉树并生成 AABB 包围盒。相机漫游时,剔除率从中心城区的约 30%(视野开阔)到街巷场景的约 85%(视野狭窄),平均帧率由 18fps 提升到 55fps,实现了秒级加载和流畅拖拽。
Python 演示
import numpy as np def make_frustum(fov_y, aspect, near, far): """构造视锥体:相机在原点看向 +Z 方向,返回 6 个平面 (n, d)。""" h_near = np.tan(np.deg2rad(fov_y) / 2) * near w_near = h_near * aspect planes = [ (np.array([0.0, 0.0, 1.0]), -near), # 近平面 (np.array([0.0, 0.0, -1.0]), far), # 远平面 (np.array([0.0, 1.0, -h_near / near]), 0.0), # 上平面 (np.array([0.0, -1.0, -h_near / near]), 0.0), # 下平面 (np.array([1.0, 0.0, -w_near / near]), 0.0), # 左平面 (np.array([-1.0, 0.0, -w_near / near]), 0.0), # 右平面 ] return planes def aabb_outside_plane(center, radius, plane): """AABB 与单个平面测试:整体在外侧返回 True。""" n, d = plane s = np.dot(n, center) + d # 中心到平面的有符号距离 proj_r = np.abs(n) @ radius # 盒子沿法线方向的投影半径 return s > proj_r def frustum_cull(planes, boxes): """对一组 AABB (center, radius) 执行视锥剔除。""" kept, culled = [], [] for center, radius in boxes: outside = any(aabb_outside_plane(center, radius, pl) for pl in planes) if outside: culled.append((center, radius)) else: kept.append((center, radius)) return kept, culled if __name__ == "__main__": rng = np.random.default_rng(42) frustum = make_frustum(60, 1.6, 0.1, 50) boxes = [] for _ in range(30): center = rng.uniform(-60, 60, size=3) radius = np.abs(rng.normal(0, 1.5, size=3)) + 0.5 boxes.append((center, radius)) kept, culled = frustum_cull(frustum, boxes) total = len(boxes) skip = len(culled) print("视锥剔除演示") print("场景物体总数: {} 个".format(total)) print("视锥内(保留): {} 个".format(len(kept))) print("视锥外(剔除): {} 个".format(skip)) print("剔除率: {:.1f}%".format(skip / total * 100))
h_near = np.tan(np.deg2rad(fov_y) / 2) * near由垂直视场角算出近裁剪面的半高,如同灯光师先确定追光灯在近处的光斑半径。planes.append((np.array([0.0, 0.0, 1.0]), -near))每条平面用“法向量 + 偏移”两个量保存,对应正文中的平面方程 N·P + D = 0。s = np.dot(n, center) + d把盒子中心代入平面方程,得到中心到光墙的有符号距离。proj_r = np.abs(n) @ radius把盒子的三个半边长投影到法线方向并加总,即“纸箱从法线方向看过去有多胖”。return s > proj_r中心已在外侧且比盒子胖还远,整个盒子必在外侧,这个面判定为“剔除”。any(aabb_outside_plane(center, radius, pl) for pl in planes)六个平面中任何一个判外,整个物体直接剔除。
常见误区
误区以为 AABB 包围盒越贴合物体越好,最好连人物的手指头都包进去,这样剔除判定才准确。
正解AABB 的价值恰恰在于“粗”。贴合度提高一点点,检测成本却上升一大截;更重要的是,视锥剔除是保守判断,多渲染一两个边缘物体完全不会出错,反而省下了大量计算。快递箱里空一点没关系,关键是测起来便宜。
为何常见:初次接触优化的人容易把“精度”当成唯一目标,忽略了性能优化本质是“在可接受误差内换取速度”。
误区觉得做了视锥剔除,游戏就一定能稳 60 帧,卡顿问题全部解决。
正解视锥剔除只解决“屏幕外”的浪费。如果画面内同时挤满一万个高模角色,帧率照样崩。屏幕内的开销要靠 LOD(层次细节)、遮挡剔除、实例化等技术继续压缩。视锥剔除是第一道关卡,不是最后一道。
为何常见:初学者容易高估单一技术的效果,忽略渲染性能是多个环节串联共同决定的。
误区把所有物体——包括跑来跑去的角色——全部塞进预先建好的八叉树里,指望一劳永逸。
正解动态物体移动后需要更新树节点,高频更新会让八叉树迅速失衡,反而拖慢剔除。工程上通常把静态物体交给八叉树/网格,动态物体用 BVH 或独立列表管理,二者分开处理。
为何常见:忽略了“静态”与“动态”在场景管理中的本质区别,被“空间划分能加速”这句话误导。
误区以为物体在视锥内就一定要渲染,视锥剔除已经是可见性处理的全部。
正解视锥内仍可能存在大量被建筑物完全挡住的物体。视锥剔除处理“屏幕外”,遮挡剔除(Occlusion Culling)处理“屏幕内但被挡住”,两者是递进关系。只做视锥剔除,城市的另一面墙后的几千栋楼依然会被白白渲染。
为何常见:两类剔除名字相似、目标重叠,初学者容易把它们混为一谈,却忽视了“看得见但被挡住”这一大块优化空间。
面试题
Q1. 视锥体由几个平面围合而成?
- A.4 个
- B.5 个
- C.6 个← 正确答案
- D.8 个
答案:C。视锥体由近裁剪面、远裁剪面、上、下、左、右共 6 个平面围成,像追光灯的六面光墙。4 个是四面体,8 个是立方体的表面数量,都不对。
Q2. AABB 中的"A"代表什么?
- A.Angular(角度的)
- B.Accurate(精确的)
- C.Axis-Aligned(轴对齐的)← 正确答案
- D.Automatic(自动的)
答案:C。AABB 全称 Axis-Aligned Bounding Box,轴对齐包围盒,指盒子的棱永远与坐标轴平行,像一个不允许旋转的快递箱。Angular、Accurate、Automatic 都不是标准含义。
Q3. AABB 与平面相交测试中,投影半径 r_p 的正确计算方式是?
- A.盒子半边长直接相加
- B.半边长与法线各分量乘积求总和← 正确答案
- C.法向量与中心距离求积
- D.盒子对角线长度取一半
答案:B。投影半径是把三个半边长分别投影到单位法向量 N 上再求和,即 r_p = r_x|N_x| + r_y|N_y| + r_z|N_z|,表示盒子沿法线方向的最大轮廓宽度,相当于从侧面看快递箱有多“胖”。直接相加、对角线取半都没有投影到法线方向,含义不对。
Q4. 关于 AABB 与视锥剔除的关系,以下说法正确的有?
- A.AABB 与坐标轴对齐,检测成本低← 正确答案
- B.AABB 比 OBB 更贴合物体形状
- C.六个平面中任一个判外则剔除← 正确答案
- D.包围盒必须完全精确包住物体
答案:AC。A 对,轴对齐让投影半径计算退化为绝对值点积,成本极低。C 对,只要有一个平面判定盒子整体在外侧,物体必不可见。B 错,OBB 可以旋转因此更贴合,AABB 往往更“空”。D 错,剔除是保守的,包围盒粗一些不影响正确性。
Q5. 视锥剔除通常发生在渲染管线的哪个阶段?
- A.像素着色阶段
- B.纹理采样阶段
- C.提交绘制命令之前的 CPU 端← 正确答案
- D.帧缓冲输出阶段
答案:C。视锥剔除发生在 CPU 端的应用阶段,即提交 Draw Call 之前。被剔除的物体根本不会进入 GPU 管线的几何处理、光栅化和像素着色,因此省下的是整条管线开销。像素着色、纹理采样、帧缓冲输出都发生在 GPU 内部,无法阻止物体进入管线。
Q6. 某 AABB 中心到平面的有符号距离 s = -3,投影半径 r_p = 4,法向量指向视锥外侧,该盒子状态是?
- A.整个盒子在外侧,剔除
- B.与平面相交,保留← 正确答案
- C.整个盒子在内侧,保留
- D.数据不足,无法判定
答案:B。盒子沿法线方向的范围是 s - r_p 到 s + r_p,即 -7 到 1。该范围跨越了平面(0 点),所以盒子与平面相交,不能被剔除。只有 s > r_p 才整体在外侧,s < -r_p 才整体在内侧,这里都不满足。
Q7. 八叉树剔除中,设单层剔除率 p = 0.75,期望访问节点数 E ≈ 1 + 8(1-p) + 64(1-p)² 约等于多少?
- A.约 4 个
- B.约 7 个← 正确答案
- C.约 9 个
- D.约 11 个
答案:B。代入 p = 0.75:1 + 8×0.25 + 64×0.0625 = 1 + 2 + 4 = 7。这意味着平均只需访问约 7 个节点就能完成整棵树的视锥判定,而树可能包含上百个节点。这正是空间划分剪枝的价值——大部分块在浅层就被整块跳过。
Q8. 工程中提升视锥剔除效果的正确做法包括哪些?
- A.静态物体合并后进行批处理剔除← 正确答案
- B.动态角色高频写入静态八叉树
- C.用层次包围体先测父节点← 正确答案
- D.用剔除率指标持续监控效果← 正确答案
答案:ACD。A 对,静态合并既减少检测次数又减少 Draw Call。C 对,先测父节点可整棵子树跳过,是 BVH 的核心。D 对,剔除率是重要的性能体检指标。B 错,动态物体频繁更新会让静态八叉树失衡,应单独管理。
Q9. 视锥剔除与遮挡剔除的关系是?
- A.两者作用完全相同
- B.视锥管屏幕外,遮挡管屏幕内← 正确答案
- C.遮挡管屏幕外,视锥管屏幕内
- D.两者互斥只能选一个
答案:B。视锥剔除过滤掉相机看不到的屏幕外物体;遮挡剔除处理“在视锥内但被楼宇、山体完全挡住”的物体。两者是递进关系,通常配合使用,而不是二选一。
Q10. 视锥体主要由哪两类平面组成?
- A.近平面与远平面← 正确答案
- B.上下左右四个侧面← 正确答案
- C.颜色平面与深度平面
- D.光照平面与阴影平面
答案:AB。视锥体的 6 个平面包括一对裁剪平面(近平面、远平面)和四个侧面(上、下、左、右)。颜色、深度、光照、阴影都是渲染阶段的概念,与视锥体的几何构成无关。