《可见性剔除系统:视锥、BVH、Hi-Z 与 GPU Driven 剔除》

渲染性能的第一性原理是「不画看不见的东西」。本文系统讲解可见性剔除体系:视锥剔除的六平面测试与包围体、BVH/Octree 空间加速结构的构建与遍历、Hi-Z 遮挡剔除的层次深度金字塔与两阶段剔除、GPU Driven 剔除的计算着色器实现与原子压缩、实例化与间接绘制,以及剔除与 LOD 的协同与滞后策略,并给出完整的工程落地方案。

图形学里最有效的优化不是「画得更快」,而是「根本不画」。一个开放世界场景可能有数十万个物体,但任一时刻真正可见的往往不足 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 保守性 → 粒度选择 → 回读延迟」,逐层排查。


总结

可见性剔除系统的完整脉络:

  1. 分层:物体级视锥剔除 → 遮挡剔除 → Meshlet 级剔除,粒度越细收益越高、成本越大。
  2. 视锥剔除:6 平面 + 包围体测试,用包围球粗筛、AABB 细判,是必做的一层。
  3. 空间结构:BVH/Octree 把线性遍历变成树遍历,是十万级物体的前提。
  4. 遮挡剔除:Hi-Z 用「最远深度金字塔 + 保守测试」把遮挡判定变成纹理采样,两阶段剔除进一步提升精度。
  5. GPU Driven:计算着色器遍历 + 原子写入 + 间接绘制,让 CPU 彻底退出绘制列表管理。
  6. 协同:剔除与 LOD 必须联合决策,配合实例化与 Bindless,构成现代渲染器的规模引擎。

可见性剔除是「不做无用功」这一渲染哲学的最纯粹体现。它不提升单个物体的画质,却决定了引擎能否驾驭开放世界级的场景规模。建议继续阅读 https://plumephp.com/graphics-gpu-driven-rendering-lod/ 了解 GPU 驱动渲染的完整范式,以及 https://plumephp.com/graphics-terrain-large-world/ 理解大地形场景中的剔除实践。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「计算机图形学」更多文章

  1. 《物理模拟与渲染集成:固定步长、插值与确定性同步》
  2. 《色彩管理与 HDR 管线:线性工作流、ACES 色调映射与宽色域》
  3. 《GPU 粒子与特效系统:计算着色器、Transform Feedback 与十万级粒子》