在浏览器里造开放世界,第 22 部分:能打光的云,和必须喂饱的剔除
作者:Oleg Sidorkin,Cinevva CTO 和联合创始人
刚看到?看系列导览。那里解释了 spike 是什么,并链接了所有部分。
第 21 部分讲的是一项没有划算的渲染技术。这一部分有一项划算的,还有一项需要细心修一下才能跑起来的。Spike 43 是天空:基于物理的大气和体积云,正是这套地基让一个场景读起来像一个地方,而不是技术 demo。Spike 44 是 meshlet 风格的 GPU 剔除,那里的教训是:遮挡测试的好坏只取决于你喂给它的遮挡体。
一片出自物理、而非渐变的天空
几乎每个电影级的天气效果都依赖两块基础设施:一个基于物理的大气,让天空色和太阳色随时段从物理里得出,而不是手调的渐变;以及一团体积云,让天空有 3D 结构,而不是一张烘焙好的 cubemap。Spike 43 就在现有的 WebGPU 和 TSL 技术栈上造出这对组合,仅此而已,因为一旦这两样存在,天气栈的其余部分(雾、上帝光、湿表面、雪)就成了一系列更小的、已知的后续。
大气用的是 Hillaire 2020 模型,一组在 WGSL 计算着色器里算出的查找表。一张透射率表把太阳光积分穿过 Rayleigh、Mie 和臭氧的密度剖面,仅在太阳移动时重算。一张天空视图表每帧重烘焙,因为它便宜到为它加闸门都不值当那点代码,在地平线附近用非线性参数化以避免色带。多重散射目前用一个解析拟合代替正经的表,而日落读起来是对的,所以这条捷径藏得不错。云是 Schneider Nubis 风格、穿过一层水平板的光线步进,形状来自一张被 32³ Worley 纹理侵蚀的 128³ Perlin-Worley 纹理,两者都在启动时用 compute 烘焙、没有网络拉取,用 Beer 定律的消光、一个双瓣相位函数和一个 powder 近似来打光。关键的耦合是:云的太阳色每一步都采样同一张透射率表,所以云的光照能跟上日落,不用第二遍调参。
在一台 M1 上,整套东西在半分辨率云时落在 1.1 到 2.0 毫秒,远低于 6 毫秒预算,用约 14 MB 的 GPU 显存,跑在 100 FPS 以上。两个论点在实践中都站住了。日落是英雄镜头,是让渲染器显得电影感的那一刻,它从物理里掉出来,不用逐时段调参。还有一个意外之喜:把云板参数化在 800 米到 4000 米之间,从低视角看,地平线上远处的云读起来像深色的山脊,这就在没人建模背景地形的情况下给了世界一个背景地形。
有一个架构笔记值得记下。最自然的形态是先把天空画进交换链(swap chain),再让 three.js 用 autoClear = false 在上面画几何。这在 r184 的 WebGPU 渲染器上活不下来,因为这个标志不像在 WebGL 里那样为颜色 load op 设闸门,于是 three.js 每帧都把天空冲掉。修复办法是把 three.js 渲染进一个离屏目标,再在我们自己拥有交换链的 pass 里做最终合成(mix(skyCloud, scene, scene.alpha) 然后 ACES 然后 sRGB)。
好坏只取决于遮挡体的剔除
Spike 44 把四种渲染模式互相对标:普通前向、CPU 集群剔除、GPU compute 剔除,以及一个带 Hi-Z 遮挡剔除的 visibility buffer。Hi-Z 路径是有意思的那个,它有一个静悄悄的 bug:它的 HUD 说遮挡是开着的,但"Hi-Z killed"计数器永远精确停在 0.0%。视锥剔除是工作的,所以剔除着色器的上游没问题。遮挡那一半是个付了全价的空操作。
一次 Hi-Z 遮挡测试会把一个集群的包围盒投影到屏幕,挑一个深度金字塔的 mip 层级让屏幕矩形大约是 2×2 个纹素,在那个矩形里采样最深的遮挡体深度,如果集群最近的点仍比那个遮挡体更远就拒绝它。深度金字塔每帧构建,由一个不透明深度预 pass 给 mip 0 播种,再向上做 max-reduce。这个不透明预 pass 刻意只包含实心遮挡体,地面和逐树的树干代理,因为 alpha-test 的植被会戳出窟窿、骗过 max-reduce。
这个 bug 是几何上的,不是逻辑上的。树干代理是一个 0.5 米 × 4 米 × 0.5 米的盒子。在 30 米处它投影到屏幕上约 17 个像素。但一个典型的 50 米草集群会选 mip 5,那里每个纹素覆盖 32 个源像素。一个 17 像素的树干无法完全覆盖单个 mip-5 纹素,所以每个碰到树干的纹素也碰到了周围的地面。最初那次 2×2 的 max-reduce 会挑更大的深度值,也就是树干背后更远的地面,于是树干的深度在第一次约简时就被抹掉了。到 mip 5 时金字塔几乎处处持有地面深度,集群永远不会比地面更远,也就永远不会被遮挡掉。
修复办法是把代理做得够大、足以主宰它落在的那些纹素,按树的轮廓而非它的木头来定尺寸。一个大约 2 米 × 6 米 × 2 米的代理仍比实际树冠小,所以透过缝隙可见的叶子永远不会被过度剔除,但它大到足以熬过 max-reduce、直到那些要紧的距离,于是遮挡计数器立刻变成了非零。这个收获可以推广成生产引擎的一条规则:任何被信任为 Hi-Z 遮挡体的东西,都必须按它在屏上的轮廓来定尺寸,因为在开阔植被场景里,Hi-Z 的效力由相关 mip 上遮挡体的覆盖主导,而不是由深度测试数学的优雅程度主导。草对草的遮挡反正无法触发,因为一根草叶和它脚下的地面处在同一深度,所以真正的收益是树遮挡远处的植被、以及树遮挡树。
本章涉及的技术
Hillaire 2020 大气 LUT。 一张透射率表(太阳光穿过 Rayleigh、Mie 和臭氧剖面)仅在太阳移动时重算,加上一张每帧的天空视图表配地平线非线性参数化,给出基于物理的天空色和太阳色,随时段变化且不用手调渐变。一个解析多重散射拟合先代替完整的表,直到某个瑕疵逼着做正经烘焙为止。日落从物理里掉出来,不用逐时段调参。
Schneider Nubis 体积云。 一次穿过水平板的光线步进,形状由一张启动时烘焙、被 32³ Worley 纹理侵蚀的 128³ Perlin-Worley 纹理塑造,用 Beer 定律消光、一个双瓣相位和一个 powder 项来打光。每一步从同一张透射率表采样云的太阳色,让云光照免费地跟上日出和日落。半分辨率光线步进比全分辨率大约便宜 4 倍,在典型距离上无可见的质量损失,这是标准的生产取舍。
在 r184 上把裸 WebGPU 与 three.js 合成。 把天空画进交换链、再用 autoClear = false 在上面画 three.js 几何会失败,因为这个标志在 WebGPU 后端里不为颜色 load op 设闸门。把 three.js 渲染进一个离屏 RGBA16F 目标,再在一个拥有交换链的 pass 里做最终的 mix 加色调映射加 sRGB。
Hi-Z 遮挡剔除与遮挡体定尺寸。 一个由 max-reduce 构建的深度金字塔让一个 GPU 剔除 pass 能拒绝那些最近点位于其屏幕矩形内最深遮挡体之后的集群。如果遮挡体小到在所选 mip 上无法主宰一个纹素,这个测试就会静悄悄地什么都不做,因为第一次 max-reduce 会把遮挡体的深度替换成它背后更远的背景。遮挡体必须按它在屏上的轮廓定尺寸,而不是按它的物理核心。见 GPU 驱动的 LOD。
第 22 部分,共 29 部分。 上一篇:第 21 部分 - 一个并没有更快的“更快渲染器” 下一篇:第 23 部分 - 五十个 avatar 和房间里的一个声音 系列导览:/zh-CN/blog/2026-02-25-open-world-browser-series-guide