Skip to main content

Chapter 47: View Frustum Culling

View Frustum Culling 的核心问题是:在一帧真正提交 draw call 之前,怎样用相机的可见空间把大量物体、分块和 cluster 分成可见、不可见和边界相交三类,并把这个结果转化为更少的 CPU 提交、更少的顶点工作和更低的片元压力。读完本章后,读者应能从 view-projection matrix 提取视锥平面,比较不同包围体测试成本,设计分层剔除流程,并用帧指标判断剔除是否产生收益。

本章使用一个贯穿材料:一个城市街区场景,场景由 4096 个 spatial chunk 组成,每个 chunk 内有若干建筑、路灯、广告牌和植被实例。相机每帧更新一次 view-projection matrix,CPU 先对 chunk 的 AABB 做 broad phase,GPU compute shader 再对 chunk 内的 cluster bounds 做细粒度测试,最后把可见 cluster 写入 indirect draw buffer。这个例子能同时覆盖数学判定、数据结构、CPU/GPU 协同和性能评估。

视锥体剔除(View Frustum Culling)是一种保守可见性测试。它的目标是在不改变最终正确图像的前提下,提前排除相机视锥体外的对象。这里的“保守”表示:边界相交对象继续保留给后续管线处理;只有完全在六个视锥平面外侧的对象才被剔除。保守性是图形工程中的安全底线,因为一次误剔除会直接表现为物体闪烁、消失或阴影缺失。

视锥体剔除处在 visibility pipeline 的前段。它早于 occlusion query、Hi-Z occlusion、portal culling 和 GPU depth test。它回答的问题是“对象是否落在相机可见体积内”,遮挡剔除回答的问题是“对象虽然在相机视锥内,是否被更近的不透明几何挡住”。这两个问题的输入、证据和失败形态不同,工程中应把它们拆成独立阶段。

47.1 视锥体裁剪的基本数学原理

视锥体剔除的数学输入来自相机的 view-projection matrix。view matrix 把世界空间点变到相机空间,projection matrix 把相机空间点变到裁剪空间(clip space)。一个世界空间点 p_world 经过 p_clip = VP * p_world 后,裁剪空间中的不等式决定它是否落在可见体积内。以 D3D / Vulkan 常见深度约定为例,裁剪空间满足 -w <= x <= w-w <= y <= w0 <= z <= w 时,点位于视锥体内。OpenGL 传统约定的深度范围为 -w <= z <= w,near plane 提取公式会随之改变。

从矩阵提取平面时,先固定工程约定。下面的公式采用列向量表达,并假设 p_clip = M * p_worldM 表示 view-projection matrix,r0r1r2r3 表示矩阵的四行。D3D / Vulkan 风格深度范围下,六个世界空间平面可写成:left = r3 + r0,right = r3 - r0,bottom = r3 + r1,top = r3 - r1,near = r2,far = r3 - r2。OpenGL 风格深度范围下,near = r3 + r2,far = r3 - r2。这些 plane 最终都应归一化,使 plane.xyz 的长度为 1,后续距离计算才可以和 bounding radius 直接比较。

矩阵提取公式的来源是裁剪空间不等式。例如 left plane 来自 x >= -w,等价于 x + w >= 0xr0 与世界点的点积,wr3 与世界点的点积,所以 left plane 的系数就是 r3 + r0。right、top、bottom 和 far 的推导相同。near plane 的形式由深度范围决定,因此跨 API 移植时应先检查 clip depth convention,再检查矩阵存储布局和乘法方向。

下面的伪代码展示从 VP 提取平面并归一化的关键路径。它用于说明数据形态,实际工程需要把 row/column major、右手/左手坐标、反向 Z 和无限远投影统一纳入相机模块。

struct Plane {
float3 normal;
float distance;
};

Plane normalizePlane(float4 p) {
float invLength = 1.0f / length(p.xyz);
return Plane{p.xyz * invLength, p.w * invLength};
}

void extractFrustumPlanesD3DStyle(float4x4 vp, Plane planes[6]) {
float4 r0 = row(vp, 0);
float4 r1 = row(vp, 1);
float4 r2 = row(vp, 2);
float4 r3 = row(vp, 3);

planes[0] = normalizePlane(r3 + r0); // left
planes[1] = normalizePlane(r3 - r0); // right
planes[2] = normalizePlane(r3 + r1); // bottom
planes[3] = normalizePlane(r3 - r1); // top
planes[4] = normalizePlane(r2); // near, D3D / Vulkan depth [0, 1]
planes[5] = normalizePlane(r3 - r2); // far
}

平面判定使用有符号距离。对于一个点 p,距离为 d = dot(plane.normal, p) + plane.distance。如果约定平面法线朝向视锥内部,d >= 0 表示点位于该平面内侧。一个点要通过视锥测试,需要同时位于六个平面内侧。一个包围体要通过保守测试,只需要在每个平面上都存在可能进入内侧的部分;只要它在某个平面的外侧完全分离,就可以剔除。

这个定义可以直接映射到城市街区场景。每帧相机更新后,CPU 先提取六个平面。一个 chunk 的 AABB 如果在 right plane 外侧完全分离,chunk 内部的建筑、路灯和广告牌都无需进入 draw list。一个靠近屏幕边缘的 chunk 通常会和一个或多个平面相交,它继续保留给下一层测试。这个“outside 直接剔除、intersect 继续细分、inside 批量接受”的三态结果,是后续层级剔除的基础。

Microsoft DirectXMath 的 BoundingFrustum 文档把 frustum 表达为 origin、orientation、四个 slope、near 和 far,并提供 ContainsIntersectsGetPlanesCreateFromMatrix 等接口。这个 API 形态说明了一个工程事实:视锥体剔除通常以“从投影矩阵或相机参数构造 frustum,然后对 sphere / box / triangle 等包围体做包含或相交测试”的方式落地。项目可以使用库接口,也可以按同样的数据关系实现自己的相机剔除模块。

47.2 视锥体剔除判定算法细节

判定算法的选择取决于包围体的紧密度、更新成本和测试成本。包围体越贴合模型,误保留的对象越少;包围体越简单,每帧测试越便宜。视锥体剔除在实时渲染中通常选择多层包围体:粗层使用 sphere 或 AABB 快速排除大块不可见对象,细层使用 OBB、cluster bounds 或 meshlet bounds 减少边缘区域的误保留。

Bounding sphere 的测试成本最低。已知球心 c 和半径 r,对每个 plane 计算 d = dot(n, c) + w。如果存在任意平面满足 d < -r,球体整体位于该平面外侧,结果为 outside。若所有平面都满足 d >= r,球体整体位于视锥体内,结果为 inside。其余情况为 intersect。sphere 的优势是旋转无关,适合动态物体和粒子批次;代价是对长条形对象偏松,容易把屏幕外的细长建筑、管线和道路护栏保留下来。

AABB 的测试适合世界空间静态对象和 spatial chunk。AABB 由最小点 min 和最大点 max 定义。对一个 plane,可以根据 plane normal 的符号选择最靠近内侧方向的 positive vertex。如果 positive vertex 仍在外侧,该 AABB 整体 outside。可以再选择 negative vertex 判断整体 inside;若 negative vertex 位于某平面外侧,这个 AABB 和该平面相交。AABB 测试只需要比较和点积,适合 CPU broad phase,也适合多线程批量处理。

下面的 C++ 伪代码展示 AABB 三态判定。它故意保留清晰分支,便于调试;发布版本可以用 SIMD、批处理和 SoA layout 重写。

enum class VisibilityResult {
Outside,
Intersect,
Inside
};

VisibilityResult testAABB(const AABB& box, const Plane planes[6]) {
bool fullyInside = true;

for (int i = 0; i < 6; ++i) {
const float3 n = planes[i].normal;

float3 positive = {
n.x >= 0.0f ? box.max.x : box.min.x,
n.y >= 0.0f ? box.max.y : box.min.y,
n.z >= 0.0f ? box.max.z : box.min.z
};

float3 negative = {
n.x >= 0.0f ? box.min.x : box.max.x,
n.y >= 0.0f ? box.min.y : box.max.y,
n.z >= 0.0f ? box.min.z : box.max.z
};

if (dot(n, positive) + planes[i].distance < 0.0f) {
return VisibilityResult::Outside;
}

if (dot(n, negative) + planes[i].distance < 0.0f) {
fullyInside = false;
}
}

return fullyInside ? VisibilityResult::Inside : VisibilityResult::Intersect;
}

OBB 适合旋转后 AABB 变得过松的对象,例如斜放的桥梁、旋转广告牌、倾斜塔吊和大型车辆。OBB 可以表示为中心 c、三个正交轴 u0/u1/u2 和三组半长 e0/e1/e2。对 plane 的投影半径为 r = abs(dot(n,u0))*e0 + abs(dot(n,u1))*e1 + abs(dot(n,u2))*e2,然后继续使用 sphere 形式的 d < -r 判定 outside。OBB 的测试成本高于 AABB,但边界更紧,适合对象数量较少、单个对象代价较高的场景。

Cluster bounds 用于 GPU-driven 管线中的细粒度剔除。一个 mesh 可以被拆成多个 meshlet 或 cluster,每个 cluster 保存小范围顶点的 sphere、cone、AABB 或量化 bounds。CPU 只需要确认 mesh 所在 chunk 可能可见,GPU compute shader 再对 cluster bounds 做视锥测试,把可见 cluster 写入 draw command 或 visibility bitset。cluster 粒度越小,误保留越少;粒度过小时,bounds 数据、prefix sum、compaction 和 indirect draw 参数维护会形成新的开销。

不同包围体可以按同一组维度比较:测试成本、紧密度、更新成本、内存占用和适用对象。sphere 的测试成本最低,适合动态粗测;AABB 适合静态世界和 chunk;OBB 适合旋转对象;cluster bounds 适合 GPU 细分和大规模网格。工程中常见的错误是用一种包围体覆盖所有对象,导致某些对象测试便宜但误保留严重,另一些对象边界很紧但更新成本超过节省的渲染工作。

城市街区场景可以采用分层组合:整个街区分块使用 AABB;动态车辆使用 sphere;大型斜桥使用 OBB;高模建筑外立面使用 cluster bounds。相机转向时,AABB 可以快速剔除视野背后的整片街区;屏幕边缘的可疑 chunk 继续进入对象级测试;大型建筑内部的 meshlet 再由 GPU 根据 cluster bounds 生成 indirect draw。这样能把高频测试放在便宜包围体上,把昂贵测试集中到真正可能影响画面的对象上。

47.3 场景分块与分层剔除

分层剔除的目标是减少测试对象数量。直接对场景中每一个 renderable 做六平面测试,在对象数量少时可行;当城市、森林、开放世界和程序化场景达到数十万对象时,剔除本身会成为 CPU 开销。场景分块把对象组织成空间节点,让一次 chunk outside 判定直接排除整个子树。

Spatial chunk 是最直接的组织方式。世界被划分成网格、区域、streaming cell 或手工编辑的 sector,每个 chunk 保存自身 AABB、对象列表、材质分组信息和脏标记。相机每帧先测试 chunk AABB。outside chunk 直接跳过;inside chunk 可以把内部静态对象批量加入候选列表;intersect chunk 再逐对象或逐子节点测试。这个三态传播能减少重复平面测试,并让可见列表构建保持稳定。

BVH 和 octree 适合对象分布不均的场景。BVH 根据对象 bounds 构建二叉层级,节点越靠近叶子越贴合对象集合。Octree 按空间均匀划分,便于 streaming 和编辑器调试。两者的剔除流程相同:测试根节点 bounds;outside 停止遍历;inside 接受整棵子树;intersect 继续访问子节点。BVH 的节点包围体通常更紧,构建和动态更新成本更高;octree 的结构稳定,空节点和不均匀密度会带来遍历浪费。

Visibility list 是剔除结果进入渲染管线的接口。它通常保存可见 renderable id、chunk id、material batch id 或 draw command index。一个稳定的 visibility list 需要记录来源和粒度:来自 chunk inside 的对象是“批量接受”,来自 object test 的对象是“对象级可见”,来自 GPU compute 的对象是“cluster 级可见”。这些标记能帮助后续 profiling 判断收益来自哪个阶段,也能在画面错误时定位误剔除发生在哪一层。

动态场景需要 dirty update。静态建筑的 chunk bounds 可以离线生成;动态车辆、门、可破坏物和动画实例会改变 bounds。dirty update 的常见策略是:移动对象更新自身 bounds;若对象仍留在原 chunk 内,只更新对象列表中的 bounds;若越过 chunk 边界,更新 chunk membership;若大量对象移动,延迟到固定阶段批量重建相关节点。dirty 标记应沿层级向上传播,因为子节点 bounds 变化会影响父节点判定。

下面的流程图描述城市街区场景中的 CPU 分层剔除。它的边界是 frustum culling;遮挡、距离 LOD 和材质排序属于后续阶段。

关键路径是三态传播。outside 提供最大收益,因为整棵子树停止访问;inside 提供次级收益,因为内部对象无需逐个测试;intersect 是必要成本,因为边界区域需要更细判断。工程调试时可以在 capture 或调试视图里给三类节点上色:红色 outside、绿色 inside、黄色 intersect。若黄色节点长期占比过高,chunk 粒度可能过大或 bounds 过松;若绿色节点很少,说明相机视野经常切过过多空间节点,树结构需要重新评估。

多线程剔除应围绕 chunk 或树节点分派任务。一个常见做法是主线程提取 frustum planes,并把顶层 chunk 切成任务块;worker 线程测试 chunk、追加本地 visibility list;主线程或 job system 在末尾合并列表并交给 render pass builder。合并阶段应减少锁竞争,使用 thread-local list、prefix allocation 或 lock-free queue。剔除阶段输出的对象顺序还会影响 material sorting 和 draw batching,因此 visibility list 的设计应兼顾可见性和渲染提交顺序。

47.4 GPU 与 CPU 协同剔除策略

CPU 和 GPU 的剔除分工由数据所在位置决定。CPU 擅长处理场景层级、对象生命周期、streaming、编辑器数据和少量高层决策;GPU 擅长对大量结构一致的 bounds 做并行测试,并把结果直接写入 GPU buffer。大型场景的稳定方案通常是 CPU broad phase 加 GPU fine phase:CPU 减少候选范围,GPU 在候选范围内生成细粒度可见结果。

CPU broad phase 的输出应尽量小而稳定。它可以输出可见 chunk id、候选 mesh id 或候选 cluster range。GPU compute shader 读取这些候选范围,对 cluster bounds 执行六平面测试。可见 cluster 写入 visibility bitset、append buffer 或 compacted command buffer。随后图形 pass 使用 indirect draw 读取这些结果。Vulkan 的 vkCmdDrawIndexedIndirect 说明了 indexed indirect draw 的基本形式:命令从 buffer 中读取 draw 参数,并按 drawCountstride 执行多次绘制。这个机制使 GPU 端生成的 draw 参数能够直接进入绘制阶段。

GPU 细粒度剔除的典型资源路径如下:FrustumPlanes constant buffer 保存六个平面;CandidateRanges buffer 保存 CPU broad phase 结果;ClusterBounds buffer 保存所有 cluster 的 bounds;compute shader 写入 VisibleClusterIdsDrawCommandBuffer;graphics pass 读取 indirect buffer 并执行 draw。这个路径的关键是同步。compute 写入 draw command 后,后续 graphics pass 需要可见的 resource barrier;CPU 读取 GPU 剔除数量会形成 readback,同步点会抵消部分收益。因此 GPU-driven 管线通常把可见数量留在 GPU 端,通过 indirect count 或固定上限加 compacted buffer 执行。

下面的 HLSL 风格伪代码展示 cluster 级 frustum test 的关键结构。它省略了 prefix sum 和 draw command packing,只保留判断和写入路径。

struct PlaneData {
float4 plane;
};

struct ClusterBound {
float3 center;
float radius;
};

StructuredBuffer<PlaneData> FrustumPlanes;
StructuredBuffer<ClusterBound> ClusterBounds;
AppendStructuredBuffer<uint> VisibleClusterIds;

[numthreads(64, 1, 1)]
void CSMain(uint3 dispatchThreadId : SV_DispatchThreadID) {
uint clusterId = dispatchThreadId.x;
ClusterBound bound = ClusterBounds[clusterId];

bool visible = true;
[unroll]
for (uint i = 0; i < 6; ++i) {
float4 p = FrustumPlanes[i].plane;
float d = dot(p.xyz, bound.center) + p.w;
if (d < -bound.radius) {
visible = false;
}
}

if (visible) {
VisibleClusterIds.Append(clusterId);
}
}

这段代码的正确性来自 sphere 与 plane 的保守判定。只要 cluster sphere 未完全位于任意平面外侧,它就被写入可见列表。它的性能风险来自三处:第一,ClusterBounds 的读取带宽随 cluster 数量线性增长;第二,append buffer 会带来原子追加成本;第三,如果后续还要 compaction 和 indirect command packing,需要额外 dispatch 与 barrier。cluster 数量少时,这些成本可能超过节省的 draw 和 shading 工作。

CPU/GPU 协同需要明确同步边界。CPU broad phase 产出的 candidate buffer 上传到 GPU 后,GPU compute 和 graphics 在同一帧内串行依赖。若 CPU 还要读取 GPU 剔除后的计数,用于 UI、日志或下一帧调度,应使用延迟读取,把第 N 帧结果在第 N+1 或第 N+2 帧消费。可见性本身也可以利用 temporal coherence:上一帧可见对象在下一帧优先保留,上一帧不可见对象按较低频率复查。这样能降低抖动和查询压力。

平台差异主要体现在 indirect draw、buffer layout、barrier 和调试工具上。Vulkan、Direct3D 12 和 Metal 都能组织 GPU-driven culling,但命令签名、argument buffer、resource state、counter buffer 和 validation 信息不同。跨 API 引擎应把剔除算法、bounds 数据和 draw command 逻辑拆成平台无关层,再把 indirect command encoding 和 barrier 放在 backend 层。这样可以让同一套 visibility 逻辑在不同图形 API 上复用,同时保留平台特定的性能路径。

47.5 剔除对帧率的提升贡献

视锥体剔除的收益来自减少后续管线工作。它能减少 draw count、triangle count、vertex shader 执行、fragment overdraw、材质绑定、instance 数据读取和 CPU submit time。它也会增加新的成本:bounds 更新、层级遍历、平面测试、visibility list 合并、GPU compute dispatch、barrier 和 indirect command 维护。最终帧率是否提升,取决于节省成本和新增成本的差值。

一个实用的评估公式可以写成:net_gain = saved_submit + saved_vertex + saved_fragment + saved_bandwidth - cull_cpu_cost - cull_gpu_cost - sync_cost。这里的每一项都应绑定可观察指标。saved_submit 对应 CPU render thread 时间和 draw call 数量;saved_vertex 对应 vertex invocation、input assembly 和 post-transform cache 压力;saved_fragment 对应 overdraw、fragment invocation 和 render target 写入;cull_cpu_cost 对应 job 时间和 list merge 时间;cull_gpu_cost 对应 compute pass 时间;sync_cost 对应 barrier、readback 和 queue dependency。

评估时先建立无剔除基线。保留同一相机路径、同一分辨率、同一材质配置和同一 LOD 设置,记录每帧 draw count、triangle count、CPU submit time、GPU frame time、主要 pass 时间和 overdraw heatmap。然后启用 chunk 级剔除,观察 draw count 和 CPU submit 是否下降;再启用 object 或 cluster 级剔除,观察 triangle count、vertex work 和 GPU time 是否继续下降。每加入一层剔除都要单独测量,因为多层叠加后很难归因。

视锥体剔除在三类场景中收益明显。第一类是开放世界或城市街区,相机视野只覆盖整个世界的一小部分,outside chunk 数量稳定较高。第二类是高多边形静态场景,大量建筑背面和街区侧后方长期落在视锥体外。第三类是 GPU-driven cluster 场景,cluster 数量多,但每个 cluster bounds 便宜且可并行测试。收益较弱的场景也很明确:小房间内对象数量少、所有对象几乎都在视野内、对象 bounds 过松、每帧动态更新 bounds 成本高、GPU compute 与 graphics 之间同步过重。

误剔除和过度保留是两个主要失败形态。误剔除表现为物体在屏幕边缘消失、相机转动时闪烁、阴影投射对象缺失或反射探针内容不稳定。常见原因包括矩阵约定错误、plane normal 方向错误、near/far depth convention 错误、bounds 未覆盖动画位移、skinning 顶点超出静态 bounds、chunk membership 更新延迟。过度保留表现为剔除开启后 draw count 下降很少,或者 GPU time 基本不变。常见原因包括 bounds 过松、chunk 粒度过大、inside/intersect 分类失衡、场景结构与相机路径不匹配。

为了把剔除收益纳入日常调试,建议提供一个可视化 overlay。它至少显示当前 frustum、chunk bounds、outside/inside/intersect 颜色、可见对象数量、剔除对象数量、剔除阶段 CPU/GPU 时间和最终 draw count。RenderDoc、Nsight、Xcode GPU tools 等工具可以用于复查 draw call 数量、pass 时间和 GPU counter,但判断顺序应先由引擎内指标建立,再用工具验证具体阶段。工具截图回答“哪一帧、哪个 pass、哪个 buffer 或 draw 出现变化”,引擎指标回答“变化是否稳定地产生收益”。

城市街区场景的可复用判断顺序可以固定为六步:先确认 frustum planes 与相机矩阵约定一致;再检查 chunk bounds 是否覆盖所有静态对象;接着统计 outside、inside、intersect 三类节点比例;然后比较开启剔除前后的 draw count、triangle count 和 CPU submit time;继续检查 GPU cluster culling 的 dispatch、barrier 和 indirect draw 成本;最后沿相机路径观察画面边缘和动态对象,确认没有闪烁和消失。这个顺序先保证正确性,再评估收益,最后处理细粒度性能瓶颈。

最小自检任务

给定一个城市街区场景:相机每帧输出 VP,场景有 1024 个 chunk,每个 chunk 保存世界空间 AABB。现在启用视锥体剔除后,draw count 从 18000 降到 7200,但 GPU frame time 几乎没有变化,画面右边缘偶尔有建筑闪烁。请设计排查顺序,说明先检查哪些数据、如何判断误剔除来源,以及如何继续评估性能收益。

答案要点

先检查 VP 的矩阵约定、clip depth convention、plane 提取公式和 plane normal 方向,确认六个平面在调试视图中包住真实相机可见空间。右边缘闪烁优先检查 right plane:把闪烁建筑的 AABB、chunk AABB 和 frustum overlay 同时显示,观察 AABB 是否在可见时被分类为 outside。若对象本身仍在 chunk 内,应检查 chunk bounds 是否覆盖该建筑的完整几何、动画位移和实例变换;若 chunk bounds 正确,应检查 AABB positive/negative vertex 选择逻辑和 plane 归一化。

性能评估应把正确性修复放在前面。确认没有误剔除后,再比较开启剔除前后的 draw count、triangle count、CPU submit time、vertex invocation、fragment invocation 和主要 pass 时间。draw count 下降但 GPU frame time 不变,通常说明瓶颈留在 fragment shading、带宽、后处理、阴影 pass、同步等待或其他未被视锥剔除影响的阶段。继续用 pass 级计时和 overdraw heatmap 判断 GPU 时间是否集中在屏幕内可见像素工作上。若 CPU submit time 明显下降,本次剔除仍然对 CPU 有收益;若 GPU 端 cluster culling dispatch、barrier 或 indirect command packing 占用较高,应降低细粒度剔除频率或调整 cluster 粒度。

本章知识点总结

  • 主问题:View Frustum Culling 负责在提交 draw call 前用相机可见体积排除完全不可见对象。
  • 保守判定:只有包围体完全落在某个视锥平面外侧时,剔除才是安全的。
  • 矩阵约定:平面提取公式依赖向量乘法方向、矩阵布局和 clip depth convention。
  • 距离测试:点、sphere、AABB 和 OBB 都可以归结为对六个 plane 的有符号距离判断。
  • 包围体取舍:sphere 测试便宜,AABB 适合 chunk,OBB 更贴合旋转对象,cluster bounds 适合 GPU 细粒度剔除。
  • 三态传播:outside 停止遍历,inside 批量接受,intersect 继续细分。
  • 层级结构:spatial chunk、BVH 和 octree 的共同目标是用一次节点测试排除大量子对象。
  • 可见列表:visibility list 是剔除阶段和 render pass builder 之间的接口,应记录可见来源和粒度。
  • 协同分工:CPU 适合 broad phase 和场景生命周期管理,GPU 适合批量 cluster bounds 测试和 indirect draw 生成。
  • 同步成本:GPU 剔除的收益会被 barrier、readback、append、compaction 和 draw command packing 消耗。
  • 收益评估:剔除收益要同时观察 draw count、triangle count、CPU submit time、GPU time、overdraw 和 pass 级时间。
  • 失败排查:误剔除优先检查 plane、depth convention、bounds 覆盖、动态更新和三态分类。