技术博客

LOD 与遮挡剔除的实现现状:Godot 在这块还缺什么

第 56 / 149 篇 · 2026-02-15 · godot · 渲染管线性能优化渲染器

写完 cluster 那两篇回头看几何剔除,发现 Godot 在这块的名声——"有,但都是半成品"——只说对了一半。翻完 servers/rendering/renderer_scene_cull.cpp(4.x,约四千行)我的结论是:剔除体系的骨架是完整的,缺的是两个工业级特性。这篇先把已有的讲清楚,再说缺的那两块怎么补。

剔除决策发生在哪里

Godot 4.x 没有独立的"culling pass",剔除和场景管理是同一个类:RendererSceneCull 同时扮演"场景实例数据库"和"剔除器"两个角色。每个 GeometryInstance3D 注册进来时把自己的 AABB 挂进一棵 BVH,之后每帧做三件事:

  1. 把上一帧移动过的实例在 BVH 里原位重插(增量更新,不是全量重建);
  2. 从相机视锥对 BVH 遍历,整棵不相交的子树一起剪掉;
  3. 幸存实例逐个过距离剔除(visibility_range_*)和 Portal/Room 遮挡测试,活着的进 RenderData 交给具体渲染器。

注意这里没有任何 GPU 参与:没有 hardware occlusion query,没有 HiZ,没有 compute 剔除。相机看不见的东西确实不会被画,但"相机看得见、被墙挡住"的东西照画不误,管线靠后面的 depth test 兜底——深度测试保得住正确性,保不住 vertex 阶段的浪费。这就是 Godot 和 Unreal 在遮挡剔除上最大的差距,后面细说。

距离 LOD:hysteresis 是治抖动的

GeometryInstance3D 提供的是一套纯距离驱动的显式 LOD:

visibility_range_begin = 20.0
visibility_range_end = 120.0
visibility_range_begin_margin = 0.1   # 迟滞带宽比例
visibility_range_fade_mode = GeometryInstance3D.VISIBILITY_RANGE_FADE_SELF

naive 的距离比较在边界上会抖:物体悬停在 begin 附近,玩家转个视角带来的距离数值噪声就足以让可见性每帧翻转。Godot 的解法是给进和出各设一条阈值线,形成迟滞环:

$$ d_{\text{on}} = d_{\text{begin}}, \qquad d_{\text{off}} = d_{\text{begin}} \cdot (1 - m) $$

其中 $m$ 是 margin 比例。只有真正跨过分界线才翻转状态,带宽内的往返被上一次的状态记忆吸收。这是模拟电路里施密特触发器的标准做法,引擎直接搬到了可见性判断上。

局限也明显:距离不等于屏幕占比。一个细长栅栏斜着摆,两端距离差好几米,就会一头显示一头消失,DEPENDENCIES fade 模式缓解的是父子组一起淡出的需求,治不了这种几何问题。

网格 LOD:有,但只在导入期

多边形级别的简化 Godot 是有的,藏在导入管线里:glTF 等模型导入时 ImporterMesh::generate_lods()(内置 meshoptimizer)按合并角阈值生成多级简化网格,渲染时按物体在屏幕上的占比选级,GeometryInstance3D.lod_bias 整体偏一档。选级依据是包围球投影后的半径占比——包围球半径 $r$、距离 $d$、垂直视场角 $\theta$:

$$ \rho \approx \frac{r}{d \cdot \tan(\theta/2)} $$

$\rho$ 低于某档阈值就换对应的简化层级。这套估算在每帧的 cull 遍历里顺手算掉,不单独占 pass。

缺的部分在节点层:引擎不会自动把"远处的整栋房子"替换成低模或 billboard,没有自动化的节点 LOD。visibility_range 配合手工摆替身节点是官方答案,场景规模上来之后维护成本很现实。

遮挡剔除:只有静态手工方案

Portal/Room 是 4.x 唯一的遮挡剔除:静态几何圈进 Room,Room 之间用 Portal 相连,相机在哪个 Room 就只画 Portal 链可达的那些。原理是用房间图替代逐物体查询,代价是所有遮挡关系要你手工搭——动态物体只能当被遮挡物,不能当遮挡体。对室内关卡有用,对开放世界等于没有。

缺的两块与自救

第一块是 GPU 遮挡查询。开放世界里大量"可见"实例其实被挡住了,cluster 和 depth prepass 都救不了 vertex 浪费。自救两条路:自己做 HiZ(用上一帧深度金字塔做 GPU 剔除,结果延迟一帧),或者干脆场景分块 + 距离流式加载,把"看不见的实例"问题变成"根本不存在的实例"问题——大多数 Godot 大场景项目实际走的是后者,因为流式加载同时还解决了内存。

第二块是节点级自动 LOD / impostor。轻量替代:把远处物体分组,visibility_range + 低模替身 + fade_mode = DEPENDENCIES,让整组一起切换;要求更高就离线烘 impostor 纹理,4.4 之后有实验性的 Impostor 节点可以接这条管线。

顺序别反:先把现有工具用到极限,profile 确认瓶颈真的在剔除之外再动手自研。renderer_scene_cull 的 BVH 遍历本身很快,值得怀疑的通常是别处。

本文基于 Godot 4.x 源码 servers/rendering/renderer_scene_cull.cpp;LOD 链生成在 scene/resources/importer_mesh.cpp 的 generate_lods()。

剔除决策链一图流

Godot 4.x 剔除决策链:一次场景遍历,全部在 CPU 侧 全部实例 RendererSceneCull 增量维护 BVH 视锥 + 距离 网格 LOD 选级 Portal / Room 手工遮挡(静态) 可见集合 → 渲染列表 → 提交渲染器 已实现:视锥剔除、距离剔除、网格 LOD(导入期 meshoptimizer 简化) 缺失:GPU 遮挡查询、节点级自动 LOD、自动 impostor——大场景靠分块流式加载自救