图形学里最有效的优化不是「画得更快」,而是「根本不画」。一个开放世界场景可能有数十万个物体,但任一时刻真正可见的往往不足 1%。可见性剔除(Visibility Culling)就是把这 99% 挡在管线之外的系统。它与 https://plumephp.com/graphics-gpu-driven-rendering-lod/ 共同构成现代渲染器的「规模引擎」。本文从最基础的视锥剔除讲起,一路深入到 Hi-Z 与 GPU Driven 剔除,最后落到剔除与 LOD 的协同。
一、可见性剔除的意义与管线位置
一句话:剔除的收益随场景规模放大——场景越大,剔除越值钱,它是「规模」的第一道闸门。
1.1 为什么剔除如此重要
渲染一帧的成本大致是 成本 = 提交开销 + 顶点/光栅开销 + 像素开销。剔除主要削减前两项:减少 Draw Call、减少顶点变换、减少光栅化。对于 GPU 带宽受限的场景,剔除甚至能提升像素级性能(减少 overdraw)。
1.2 剔除在管线中的位置
场景图(Scene Graph)
↓ ① 视锥剔除(Frustum Culling)
候选集 A
↓ ② 遮挡剔除(Occlusion Culling / Hi-Z)
候选集 B
↓ ③ LOD 选择
候选集 C
↓ ④ 实例化 / 批处理
Draw Commands
↓ ⑤ 间接绘制(Indirect Draw)
GPU 光栅化
1.3 剔除层级对比
| 层级 | 方法 | 粒度 | 成本 | 收益 |
|---|---|---|---|---|
| 物体级 | 视锥剔除 | 每物体 | 低 | 中 |
| 物体级 | 遮挡剔除 | 每物体 | 中 | 高 |
| 集群级 | Meshlet 剔除 | 每集群 | 高 | 高 |
| 三角形级 | 背面/小三角形剔除 | 每三角形 | 极低 | 低 |
| 像素级 | Early-Z / Hi-Z | 每像素 | 硬件 | 中 |
一句话:剔除是分层的——视锥剔除先砍一半,遮挡剔除再砍九成,LOD 和实例化负责剩下的提交成本。
二、视锥剔除
一句话:视锥剔除测试包围体与相机视锥的六个平面——全在外则剔除,全在内则保留,相交则保留(保守)。
2.1 视锥的平面表示
相机视锥由 6 个平面界定(近、远、左、右、上、下)。每个平面用 (n, d) 表示,点 p 在平面内侧当且仅当 dot(n, p) + d > 0。
// C++:从视图投影矩阵提取 6 个视锥平面(Gribb-Hartmann 方法)
struct FFrustum {
glm::vec4 planes[6]; // (nx, ny, nz, d)
void ExtractFromMatrix(const glm::mat4& m) {
// 左、右、下、上、近、远
planes[0] = m[3] + m[0]; // left
planes[1] = m[3] - m[0]; // right
planes[2] = m[3] + m[1]; // bottom
planes[3] = m[3] - m[1]; // top
planes[4] = m[3] + m[2]; // near
planes[5] = m[3] - m[2]; // far
for (auto& p : planes) p /= glm::length(glm::vec3(p));
}
};
2.2 包围球与 AABB 测试
包围球测试最便宜:dot(n, center) + d < -radius 即完全在平面外侧,立即剔除。
// C++:包围球 vs 视锥
bool SphereInFrustum(const FFrustum& f, const glm::vec3& c, float r) {
for (int i = 0; i < 6; ++i) {
float dist = glm::dot(glm::vec3(f.planes[i]), c) + f.planes[i].w;
if (dist < -r) return false; // 完全在外侧 → 剔除
}
return true; // 相交或在内 → 保留(保守)
}
2.3 AABB 的 p-vertex 测试
AABB 测试用「最远正顶点(positive vertex)」:取 AABB 上沿平面法线方向最远的角点,若它都在平面外侧,则整个 AABB 在外侧。
// C++:AABB vs 视锥(p-vertex)
bool AABBInFrustum(const FFrustum& f, const glm::vec3& mn, const glm::vec3& mx) {
for (int i = 0; i < 6; ++i) {
glm::vec3 n = glm::vec3(f.planes[i]);
glm::vec3 pv = glm::mix(mn, mx, glm::greaterThan(n, glm::vec3(0.0f)));
if (glm::dot(n, pv) + f.planes[i].w < 0.0f) return false;
}
return true;
}
2.4 性能对比
| 包围体 | 单次测试成本 | 精度 | 适用 |
|---|---|---|---|
| 包围球 | 最低(6 dot) | 低 | 快速预筛 |
| AABB | 低 | 中 | 通用 |
| OBB | 中高 | 高 | 细长物体 |
| Convex Hull | 高 | 最高 | 关键物体 |
一句话:视锥剔除是「廉价且必然要做」的一层,用包围球先粗筛、AABB 再细判即可覆盖绝大多数场景。
三、空间加速结构
一句话:视锥剔除本身要遍历物体,空间结构(BVH/Octree)把「遍历 N 个物体」变成「遍历 log N 个节点」。
3.1 为什么需要空间结构
朴素视锥剔除要测试每个物体。当物体数达到十万级,即便每次测试很便宜,遍历成本也吃不消。空间结构把物体组织成层次包围体,一次剔除整棵子树。
3.2 结构对比
| 结构 | 划分方式 | 构建成本 | 查询 | 适用 |
|---|---|---|---|---|
| BVH | 按空间划分,节点可重叠 | 中 | 快 | 通用、光线追踪 |
| Octree | 均匀八分空间 | 低 | 快 | 静态场景、地形 |
| KD-Tree | 按轴交替划分 | 中 | 快 | 静态、点查询 |
| Grid | 均匀网格 | 极低 | 中 | 均匀分布物体 |
| BVH8/16 | 宽 BVH | 中高 | 最快 | GPU 光线追踪 |
3.3 BVH 构建
BVH 递归地把物体集合分成两半,用 SAH(Surface Area Heuristic)选择最佳分割:
// C++:BVH 节点与 SAH 分割
struct FBVHNode {
glm::vec3 boundsMin, boundsMax;
int leftChild; // 内部节点:左子索引
int firstPrim; // 叶节点:首个图元
int primCount; // 叶节点:图元数
};
// SAH 代价:节点表面积 × 图元数,面积 = 2(dx·dy + dy·dz + dz·dx)
float SAH(const glm::vec3& mn, const glm::vec3& mx, int n);
3.4 遍历与剔除
// C++:BVH 视锥剔除遍历
void CullBVH(const FBVHNode* nodes, int nodeIdx, const FFrustum& f,
std::vector<int>& outVisible) {
const FBVHNode& n = nodes[nodeIdx];
if (!AABBInFrustum(f, n.boundsMin, n.boundsMax)) return; // 整棵子树剔除
if (n.primCount > 0) { // 叶节点
for (int i = 0; i < n.primCount; ++i)
outVisible.push_back(n.firstPrim + i);
} else {
CullBVH(nodes, n.leftChild, f, outVisible);
CullBVH(nodes, n.leftChild + 1, f, outVisible);
}
}
一句话:空间结构把剔除从「线性扫描」变成「树遍历」,是十万级物体剔除的前提。
四、遮挡剔除与 Hi-Z
一句话:视锥剔除只能砍掉视野外的物体,遮挡剔除进一步砍掉「在视野内但被挡住」的物体——这才是最大的一块。
4.1 遮挡剔除的直觉
站在走廊里,墙后的房间虽然部分在视锥内,却完全看不见。遮挡剔除就是识别并跳过这些物体。
4.2 软件遮挡查询
D3D 的 Occlusion Query 与 OpenGL 的 GL_ANY_SAMPLES_PASSED 让 GPU 报告「某包围盒是否产生了像素」。但逐物体查询有延迟且昂贵,现代方案转向 Hi-Z。
4.3 Hi-Z(Hierarchical Z-Buffer)
Hi-Z 把深度缓冲逐级降采样成金字塔,每级存 2×2 块中的最远深度(max,用于保守测试):
Level 0: 1024×1024 原始深度
Level 1: 512×512 每 2×2 取 max
Level 2: 256×256
...
Level N: 1×1
测试物体时,把包围盒投影到屏幕,选合适的 mip 级别,比较「包围盒最近深度」与「该区域最远深度」:若包围盒最近深度 > 该区域最远深度,说明物体完全在已有几何之后,可剔除。
// HLSL:Hi-Z 遮挡测试(在 compute 中)
bool IsOccluded(float2 aabbMinUV, float2 aabbMaxUV, float nearestDepth, Texture2D<float> hiZ) {
float2 size = aabbMaxUV - aabbMinUV;
// 选 mip 级别:让测试区域落在 1~2 个 texel 内
float mip = ceil(log2(max(size.x, size.y) * hiZResolution));
// 采样该区域的最远深度(Hi-Z 用 max 保证保守)
float farthestDepth = hiZ.SampleLevel(samplerLinearClamp, (aabbMinUV + aabbMaxUV) * 0.5, mip);
// 物体最近点仍比场景最远点还远 → 被遮挡
return nearestDepth > farthestDepth;
}
4.4 两阶段剔除(Two-Phase)
单帧内先用上一帧的 Hi-Z 做剔除,渲染不透明几何时更新 Hi-Z,再做一次更精确的剔除:
| 阶段 | 数据来源 | 精度 | 用途 |
|---|---|---|---|
| Phase 1 | 上帧 Hi-Z | 保守(可能漏剔) | 提前剔除大批物体 |
| Phase 2 | 本帧 Hi-Z | 更精确 | 细粒度剔除 |
4.5 遮挡剔除的取舍
| 方案 | 精度 | 成本 | 延迟 |
|---|---|---|---|
| Occlusion Query | 高 | 高 | 1~N 帧 |
| Hi-Z(软件) | 中高 | 低 | 1 帧 |
| 硬件 Hi-Z | 高 | 极低 | 无 |
一句话:Hi-Z 用「最远深度金字塔 + 保守测试」把遮挡剔除变成几次纹理采样,是现代引擎的标配。
五、GPU Driven 剔除
一句话:把剔除搬到 GPU——用计算着色器遍历场景、写间接绘制参数,CPU 完全不参与。
5.1 动机
CPU 剔除的问题:单线程遍历十万物体、每帧上传绘制列表、缓存一致性差。GPU Driven 剔除把这一切放进一个 compute pass。
5.2 实现结构
// HLSL:GPU 视锥剔除 + 间接绘制参数写入
struct FInstanceData {
float4x4 transform;
float3 boundsCenter;
float boundsRadius;
uint meshIndex;
uint lodLevel;
};
StructuredBuffer<FInstanceData> instances;
RWStructuredBuffer<uint> visibleIndices;
RWStructuredBuffer<FDrawArgs> drawArgs; // 每个 mesh 一份
RWStructuredBuffer<uint> drawCounters;
[numthreads(64, 1, 1)]
void CullInstances(uint3 id : SV_DispatchThreadID) {
uint i = id.x;
if (i >= instanceCount) return;
FInstanceData inst = instances[i];
float3 center = mul(inst.transform, float4(inst.boundsCenter, 1)).xyz;
float radius = inst.boundsRadius * maxScale(inst.transform);
// 视锥测试(6 平面)
if (!SphereInFrustum(center, radius)) return;
// Hi-Z 遮挡测试
if (IsOccludedByHiZ(center, radius)) return;
// 通过:追加到可见列表
uint slot;
visibleIndices.InterlockedAdd(drawCounters[inst.meshIndex], 1, slot);
visibleIndices[drawCounters[inst.meshIndex] * MAX_PER_MESH + slot] = i;
}
5.3 间接绘制参数
剔除结果直接写成 DrawIndexedInstancedIndirect 的参数,CPU 端只需一次 draw 调用(甚至零调用,用 ExecuteIndirect):
// C++:间接绘制参数布局
struct FDrawIndexedArgs {
uint32 IndexCountPerInstance;
uint32 InstanceCount; // GPU 写入
uint32 StartIndexLocation;
int32 BaseVertexLocation;
uint32 StartInstanceLocation;
};
// CPU: cmd->DrawIndexedInstancedIndirect(argsBuffer, meshIndex * sizeof(FDrawIndexedArgs));
5.4 与 Meshlet 结合
更激进的方案把网格切成 meshlet(~64 顶点),在 meshlet 级做剔除,能进一步提升剔除精度,但需要 meshlet 剔除与集群 LOD 支持。这与 https://plumephp.com/graphics-gpu-driven-rendering-lod/ 中的集群 LOD 是同一体系。
| 剔除粒度 | 物体数 | 精度 | 复杂度 |
|---|---|---|---|
| 物体级 | 10⁴~10⁵ | 低 | 低 |
| Meshlet 级 | 10⁶~10⁷ | 高 | 高 |
一句话:GPU Driven 剔除的收益不只是速度,更是「让 CPU 从绘制列表的搬运工里彻底解放」。
六、实例化与间接绘制
一句话:剔除决定「画什么」,实例化与间接绘制决定「怎么低成本地画」——两者缺一不可。
6.1 硬件实例化
同一网格的多个实例用一次 draw 调用,每实例数据通过 SV_InstanceID 索引:
// HLSL:实例化顶点着色器
StructuredBuffer<float4x4> instanceTransforms : register(t0);
VSOutput VS(VSInput input, uint instanceID : SV_InstanceID) {
float4x4 mvp = mul(viewProj, instanceTransforms[instanceID]);
VSOutput o;
o.position = mul(mvp, float4(input.position, 1.0));
return o;
}
6.2 批处理层级
| 手段 | 削减对象 | 前提 |
|---|---|---|
| 静态合批 | Draw Call | 静态、同材质 |
| GPU 实例化 | Draw Call | 同网格 |
| 间接绘制 | CPU 往返 | GPU 写参数 |
| Bindless | 描述符绑定 | 现代 API |
6.3 与剔除协同的完整流程
① GPU Cull Pass:遍历实例 → 视锥 + Hi-Z → 写可见索引
② GPU Compact:压缩可见实例(前缀和)
③ GPU Write Args:写 DrawIndexedInstancedIndirect 参数
④ CPU:一次 ExecuteIndirect 提交所有网格
⑤ GPU:光栅化可见实例
一句话:实例化解决「重复物体的提交成本」,间接绘制解决「CPU 往返」,剔除解决「不必要的绘制」——三者是同一套 GPU 驱动哲学。
七、剔除与 LOD 的协同
一句话:剔除回答「画不画」,LOD 回答「画多细」,两者必须一起决策,否则会出现「剔了但没降级」的浪费。
7.1 为什么必须协同
一个物体通过了视锥剔除但在屏幕上是 3 个像素——此时正确的做法是选最粗的 LOD,而不是用原始网格。剔除系统应输出「可见 + LOD 级别」的联合结果。
7.2 联合决策流程
// C++:剔除 + LOD 联合决策
struct FVisibleInstance {
uint32 instanceIndex;
uint32 lodLevel;
float screenSize;
};
void CullAndSelectLOD(const FInstanceData& inst, const FCamera& cam,
FVisibleInstance& out) {
float dist = glm::distance(cam.position, inst.boundsCenter);
// 屏幕空间误差决定 LOD
float screenSize = (inst.boundsRadius * 2.0f) / dist * cam.projectionScale;
uint32 lod = SelectLOD(screenSize, inst.lodThresholds);
if (lod >= inst.lodCount) return; // 太小,直接剔除
out = { inst.index, lod, screenSize };
}
7.3 与 Hi-Z 的顺序
| 顺序 | 优点 | 缺点 |
|---|---|---|
| 先 LOD 后剔除 | LOD 影响包围盒,剔除更准 | LOD 计算成本前置 |
| 先剔除后 LOD | 剔除快 | 需为每级 LOD 存包围盒 |
工程上常用「先视锥剔除 → 再 LOD 选择 → 再 Hi-Z 剔除」,因为 Hi-Z 测试依赖屏幕尺寸。
7.4 剔除边界与弹出
LOD 切换会导致物体突然变精细(pop)。需用滞后(hysteresis) 避免边界抖动:进入阈值与退出阈值不同。
// C++:LOD 滞后,避免边界抖动
uint32 SelectLODWithHysteresis(float screenSize, uint32 currentLOD,
const float* enterThresholds,
const float* exitThresholds) {
if (currentLOD > 0 && screenSize < enterThresholds[currentLOD - 1])
return currentLOD - 1; // 变精细
if (currentLOD < maxLOD && screenSize > exitThresholds[currentLOD])
return currentLOD + 1; // 变粗糙
return currentLOD;
}
一句话:剔除与 LOD 是同一次决策的两个输出,合并处理才能避免「过剔除」和「过绘制」。
八、工程实践与常见问题(FAQ)
Q:剔除后物体闪烁出现?
A:多为 Hi-Z 保守性不足(用了 min 而非 max)或上一帧深度未清空,导致误剔。检查 Hi-Z 的深度方向与采样 mip。
Q:视锥剔除漏剔/误剔?
A:检查平面提取的矩阵约定(行主序 vs 列主序)、深度范围(GL 的 [-1,1] vs D3D 的 [0,1])、以及包围体是否随变换更新。
Q:动态物体怎么进 BVH?
A:静态用 BVH/Octree,动态用另一套结构(如动态 AABB 树),或每帧重建局部。
Q:剔除收益不明显?
A:检查瓶颈是否在别处(带宽、像素)。剔除主要省 CPU 提交与顶点,对纯像素受限场景收益有限。
Q:小物体剔除反而更慢?
A:对每个小物体做 Hi-Z 测试的成本可能超过直接绘制。设置「最小屏幕尺寸阈值」,低于阈值直接剔除或合并。
一句话:剔除系统的调试顺序是「视锥正确性 → Hi-Z 保守性 → 粒度选择 → 回读延迟」,逐层排查。
总结
可见性剔除系统的完整脉络:
- 分层:物体级视锥剔除 → 遮挡剔除 → Meshlet 级剔除,粒度越细收益越高、成本越大。
- 视锥剔除:6 平面 + 包围体测试,用包围球粗筛、AABB 细判,是必做的一层。
- 空间结构:BVH/Octree 把线性遍历变成树遍历,是十万级物体的前提。
- 遮挡剔除:Hi-Z 用「最远深度金字塔 + 保守测试」把遮挡判定变成纹理采样,两阶段剔除进一步提升精度。
- GPU Driven:计算着色器遍历 + 原子写入 + 间接绘制,让 CPU 彻底退出绘制列表管理。
- 协同:剔除与 LOD 必须联合决策,配合实例化与 Bindless,构成现代渲染器的规模引擎。
可见性剔除是「不做无用功」这一渲染哲学的最纯粹体现。它不提升单个物体的画质,却决定了引擎能否驾驭开放世界级的场景规模。建议继续阅读 https://plumephp.com/graphics-gpu-driven-rendering-lod/ 了解 GPU 驱动渲染的完整范式,以及 https://plumephp.com/graphics-terrain-large-world/ 理解大地形场景中的剔除实践。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。