不要渲染山丘后面的草:地形感知遮挡剔除
作者:Oleg Sidorkin,Cinevva CTO 兼联合创始人
第 28 部分介绍了 Spike 57,并用几段文字对其中四条剔除路径进行了基准测试。本文是完整版本:为什么选择这四条、另外四条是什么,以及我们决定发布当前方案背后的思考。
摄像机和草地之间有一座山丘。你不需要渲染那片草地。所有现代 AAA 引擎都明白这一点,但大多数基于浏览器的 3D 引擎并不明白——直到上周,我们自己的引擎也一样。
本文来自一次扎实的研究:我们将方案落实到实际代码库中,构建了可运行的技术验证,并根据它们对我们这个具体项目的帮助程度进行评估,因为我们目前发布到 WebGL,未来则会迁移到 WebGPU。
继续阅读之前,你可以先试用下面的在线技术验证。按 T/Y/U/I 可切换不同的剔除路径,按 C 可循环切换摄像机预设,按 B 会用红色线框标出被隐藏的区块,让你看到测试究竟移除了哪些内容。HUD 会显示每个阶段剔除了多少实例。
我们的引擎目前做了什么(又没做什么)
我们的流式区块管理器会围绕玩家加载一圈 64 米区块,并使用三个 LOD 层级。每个区块都拥有散布在高度图上的实例化树木。剔除逻辑位于 Chunk.updateObjectVisibility 中,目前只是简单的距离检查:60 米以内的树木以完整网格渲染,60 米到 140 米之间的树木以公告板渲染,超过这一距离则完全不渲染。
这种剔除遗漏了两大类巨大的浪费:
- 位于 60 米圆形范围内、但处于摄像机后方的所有内容。每当玩家移动超过 4 米时,我们都会重新排序实例缓冲区,但不会测试视锥体,因此平均而言,圆形范围的一半都会被送入 GPU,最终却只在顶点变换后被裁掉。
- 位于 60 米圆形范围内、但处于山丘后方的所有内容。我们的地形有 80 米的垂直高度范围,而且包含许多山谷。当摄像机位于山谷中时,剔除半径内的大部分植被都会被最近的山脊从几何上遮住,但我们仍然会渲染它们。
本文主要关注第二类浪费。这也是 AAA 引擎会用一整套巧妙方案解决的问题,而乍看之下,这些方案并不能顺畅地移植到浏览器中。
Guerrilla 的 Gilbert Sanders 讲解了《地平线:零之曙光》如何渲染开放世界植被。最后 20 分钟介绍了完整的剔除流程,堪称说明“少画一些”为什么胜过“画得更快”的大师课。
针对我们场景评估的八种技术
我研究了现代遮挡剔除技术栈,并根据每项技术对程序化、基于高度图的浏览器开放世界有多大帮助进行了评分。评估包含两个维度:价值(针对我们的场景,它能消除多少浪费)和成本(工程投入,以及它会迫使我们改动多少现有技术栈)。完整分析位于本文背后的研究文件中;以下是简要版本。
1. 逐实例视锥体剔除。价值高,成本低。 目前,我们完全没有对实例化植被进行视锥体测试。加入这项测试可以在任何视角下将工作量减少约一半,只需要大约三十行代码,而且现在就能在 WebGL 中发布。这是成本最低、收益最大的一项改进,我们几个月前就该做了。
2. 高度图地平线射线检测。价值高,成本低至中等。 从摄像机向每个候选实例发射一条射线,并沿途采样地形高度。如果地形在任何位置高于射线,该实例就被遮挡。它之所以有效,恰恰是因为我们的世界采用高度图,这让可见性问题沿水平方向简化成了一维问题。密集版本每帧对每个实例的复杂度为 O(步数)。加速版本(下一项)则将其降至 O(log 步数)。
3. 最大高度金字塔加速。价值高,成本中等。 它是高度图的 2D mipmap,其中每个纹素都保存其覆盖区域内的最大地形高度。当地形平坦时,地平线射线检测可以大步跳跃,只在山丘附近进行细化。这一数据结构让第 2 项方案足够便宜,可以用于生产环境。
4. 分层 Z 遮挡(Hi-Z / HZB)。价值高,成本高。 构建深度缓冲区 mipmap,将每个实例的边界投影到屏幕空间,再使用合适的 mip 层级进行测试。这是现代 GPU 端的标准方案,Unreal 的 Nanite、Bevy 的虚拟几何体以及 VTK 的 WebGPU 移植版都在使用。它适用于所有内容,而不仅仅是地形,但需要 WebGPU、间接绘制和一个计算通道。当我们渲染的是数百万根草叶,而不是数千棵树时,它才真正值得投入。
5. 两遍 Hi-Z(Nanite 风格)。相较第 4 项收益有限,成本高。 在第一遍之后重新渲染当前帧的深度,以避免单帧解除遮挡伪影。只有当 GPU 驱动路径已经推进到足够成熟的阶段,使额外成本变成增量投入时,它才值得采用。
6. 软件遮挡光栅器(Frostbite/Intel MOC)。价值中等,成本高。 在 CPU 上对大型遮挡物进行光栅化,生成低分辨率深度缓冲区。它没有回读延迟。参考实现采用 AVX/SSE C++;移植到 WASM 是一个真正的大项目,而我们的高度图射线检测只需很少的工作量,就能获得其中大部分收益。
7. 预计算 PVS。价值低,成本高。 对《Quake》时代的静态地图而言非常漂亮。我们的地形是程序化且无限的,因此任何预处理都必须在区块流式加载时完成,其成本和直接执行运行时可见性测试差不多。跳过。
8. Cesium 风格的地平线剔除。对我们毫无价值,成本中等。 它是为行星椭球体设计的。我们的世界基本平坦且有边界;这套数学并不适用,要么不起作用,要么会发生错误剔除。跳过。
所以:现在就在 CPU 和 WebGL 上实现第 1 项以及第 2+3 项。为迁移到 WebGPU 规划第 4 项。其余全部跳过。
为什么高度图射线检测最适合浏览器地形
任何现代渲染演讲中的标准建议都是“构建 Hi-Z 缓冲区”。Brian Karis 在 SIGGRAPH 2021 上对 Nanite 的深入解析是权威参考,值得至少看一次。
对于已经通过 GPU 驱动的间接绘制处理一切的引擎来说,这是正确答案。大多数浏览器引擎都不是这样的引擎,我们也一样。我们使用 CPU 端实例缓冲区、WebGL 绘制调用,而且没有计算阶段。将 Hi-Z 强行接入这套技术栈,意味着要同时移植到 WebGPU、把植被管线重写成间接绘制,并增加深度金字塔构建通道。仅仅为了得到证明这个想法可行的第一帧,就要投入一个季度的工作。
高度图射线检测可以在 CPU 和 WebGL 中运行,并直接使用我们已有的数据。它利用了一个 AAA 引擎无法利用的世界特征:我们的遮挡物由一维高度函数描述。沿射线采样这个函数只需要两次数组查找和一次乘法。而 Hi-Z 缓冲区必须逐像素重新发现同一个事实。
这项技术最早发表于 IEEE Visualization 2002,论文题为“Horizon Occlusion Culling for Hierarchical Terrains”(PDF)。二十年来,它始终留在开发者的工具箱中,因为它具备恰当的特性:地形平坦时成本低,只有真正存在山丘时才会变得昂贵,而且极易并行化。
最大高度金字塔图解
朴素射线检测沿每条射线在大约 24 个点采样 terrainHeight,如果地形穿过射线就提前结束。对数千棵树而言,这完全没问题。但面对数十万根草叶时,它就撑不住了。
解决方法是构建高度图 mipmap,其中每个纹素都保存其覆盖区域内的最大高度:
level 0 (256×256, 2.25m per texel): max h of 4×4 jittered samples
level 1 (128×128, 4.5m per texel): max(0,0), max(1,0), max(0,1), max(1,1)
level 2 ( 64×64, 9.0m per texel): same reduction one level up
...
level 8 ( 1×1, 576m per texel): global max当射线段很长且地形平坦时,就采样较粗的层级:一次查找便能告诉你,“这个 9 米见方的区域内,没有任何地形超过 12 米海拔,而射线在这里位于 30 米高度,所以可以继续前进。”只有当粗粒度纹素表明“地形可能高于射线”时,才会下降一个层级并进行细化。整个结构只占几百 KB,几十毫秒即可构建完成。
示意图如下:
ray from eye
eye 1.7m o─────────────────────►
o─────────────────────────·─·─·─·─·─·──────────────
│ \ │
│ level 3 (huge step) \ level 0 (refine) │
│ "no terrain above 8m" \ "9m hill here!" │
│ \ │
──────┴────────────/▔▔▔\─────────────/▔▔▔▔▔\─────────────
hill A (8m) hill B (12m)
↑
blocks here对于经过山丘 A 附近的射线段,level 3 查找(“这个 18 米宽的方框内最大高度为 8 米”)已经能告诉我们,位于 1.7 米高度并向上爬升了几米的射线不会被阻挡。一次查询就能跳过 36 米的步进。到了山丘 B 上方,level 3 会显示“这里的最大高度为 12 米”,于是我们向下细化;level 0 会显示“是的,这个确切纹素处的高度为 12 米”,然后我们剔除该实例。
金字塔构建代码位于 height-pyramid.mjs,使用它的剔除路径则位于 cull.mjs。两者都可以在 Spike 源代码浏览器中阅读。
这个技术验证实际展示了什么
打开上面的 Spike 57,依次体验四条路径:
- T0 是当前生产环境使用的方案。只检查距离。在 C1(谷底)视角下,HUD 会显示约 12,000 根草叶可见。
- T1 增加逐实例视锥体剔除。可见数量大约减半,因为位于摄像机后方或侧面视野之外的所有内容,都会在抵达 GPU 之前被丢弃。
- T2 增加暴力高度图射线检测。在 C1 中,数量会再减少 60–80%,因为大部分草地都位于最近山脊的另一侧。Cull-ms 列会上升,因为每个实例都要采样
terrainHeight大约 24 次。 - T3 用最大高度金字塔替换暴力检测。在保留可见性收益的同时,Cull-ms 会回落到接近 T1 的水平。这才是你真正应该发布的路径。
这种模式与生产级引擎观察到的情况一致。Acerola 关于草地渲染的上下两期视频(游戏如何渲染如此多的草?、我是如何优化游戏草地的)是 YouTube 上最容易理解的讲解,说明了为什么工程投入应该花在剔除阶段,而不是着色阶段。
摄像机 C3 与“山顶”失效场景
技术验证的第三个摄像机预设会把摄像机放在山顶,俯瞰整个游玩区域。在这种情况下,地平线剔除几乎不起作用,因为摄像机和世界大部分区域之间没有地形。HUD 显示,与 T1 相比,T2/T3 可能只能让可见数量减少 5–10%。
这是特性,不是缺陷。这项技术恰好会在它理应失效的地方停止发挥作用:没有任何东西可用于遮挡时。视锥体剔除仍然在做实质性的工作,距离剔除仍然在限制预算,而地平线测试则会自然退化成空操作。如果你要实现这项技术,还需要验证空操作场景本身也足够便宜。这正是最大高度金字塔的重要之处,即使没有任何射线会被剔除,通常只需最粗层级就能确认“没有任何内容被遮挡”。
接下来要发布什么
接下来有三件事要做,并且需要按顺序进行。
首先,把 T1 和 T3 移植到生产环境的 Chunk.updateObjectVisibility 中。金字塔应该放在更上一级的区块管理器中,因为它会跨越多个区块。剔除逻辑仍留在 Chunk 中,这样现有的逐区块批处理就能继续工作。预计工作量:一天,包括测试。 第二,等草地系统就绪后,对草也采用同样的方案。当前的技术验证版本在 576 米的游玩区域内散布了 12,000 片草叶;正式版本的密度应该大约再高一个数量级。CPU 剔除路径能在一毫秒内处理完 12,000 片草叶,而金字塔加速结构可以确保实例数达到 120,000 时依然如此。
第三,当我们迁移到 THREE.WebGPURenderer 时,把同一个循环移植到计算着色器中。元数据会变成存储缓冲区。剔除过程会写入 drawIndirect 参数。金字塔则作为一张已预先完成最大值归约的 2D 纹理上传。代码的整体结构几乎保持不变,而这正是关键所在:我们不会把迁移押在一种新算法上,而是把已经验证过的算法搬到更快的执行平台上。
Guerrilla 曾介绍过《地平线:零之曙光》程序化放置系统在 GPU 端的实现;相比渲染管线,数据结构更加重要,而他们的数据结构与这里采用的结构相同:
等我们加入建筑和密集道具后,Hi-Z 仍会发挥作用,因为这些对象会在高度图无法描述的方向上形成遮挡。但高度图金字塔仍会保留在管线中,因为对于贴近地面的实例,它的成本一定比 Hi-Z 更低;而横跨山脊轮廓线的草,恰恰是 Hi-Z 最难处理的情况。
参考资料
上文已经给出了完整的方案优先级依据和架构决策;以下是各项技术的权威资料,大致按照它们在技术栈中的出现顺序排列。
- Yalim 与 Akman,用于分层地形的地平线遮挡剔除(IEEE Visualization 2002,最早提出地形地平线剔除的论文)。
- Turitzin,分层深度缓冲区(关于如何构建和查询 Hi-Z MIP 链最清晰的说明)。
- VKGuide,基于计算着色器的剔除(GPU 驱动剔除管线的参考资料;移植到 WebGPU 基本只是机械性工作)。
- Kruskonja,双遍遮挡剔除(在 Epic 幻灯片之外对 Nanite 风格架构的讲解)。
- Karis 等人,Nanite——深入解析(SIGGRAPH 2021)(了解 AAA 规模 GPU 驱动剔除的现代权威资料)。
- Karis,HPG 2022 主题演讲:Nanite 的诞生历程(解释剔除阶段为何重要的更广泛背景)。
- Sanders,技术与艺术之间:《地平线:零之曙光》的植被(端到端介绍 AAA 植被剔除技术栈)。
- Guerrilla,《地平线:零之曙光》中基于 GPU 的运行时程序化放置(实例化放置和逐实例可见性判断)。
- Scthe,Nanite WebGPU(一个包含完整 HZB 的 WebGPU 参考实现)。
- Kitware,VTK 中的 WebGPU 遮挡剔除(据我所知,这是首个用于浏览器端生产环境的 HZB)。
- Acerola,游戏如何渲染如此多的草?和我如何优化游戏中的草(通俗易懂的 YouTube 导览)。
- RasterGrid,基于 Hi-Z 图的遮挡剔除(至今仍堪称权威的 Hi-Z 入门资料)。
- Intel,掩码式软件遮挡剔除(CPU 光栅化器方案系列,供你以后认为 #6 值得采用时参考)。