前言


此天空实现大量参考了Godot源码主要贡献者ClayJohn在一个开源Repo里的实现,并作出了大量修改和扩展。本文会详细解析他的实现并说明我自己做了什么样的内容以及扩展。
现在的天空包含完整的基于物理的昼夜循环,以及高质量(几乎无噪音)的体积云渲染,且速度极快。
这套实现天空部分比较标准,但是体积云部分的渲染策略非常罕见,性能比起3A游戏的普遍标准是数量级级别的好(ClayJohn自己的代码就是这种实现,我基本抄了),但是也有相当的局限性(我通过一些Trick进行了改进,但是治标不治本),是一个需要取舍的工程问题。
如果你需要一个标准的3A级别的体积云实现,你可以使用GodotAssetLib里面的SunshineClouds这个插件。但是这个插件性能问题不小,且云的降噪写的质量比较差,实际视觉效果可能不理想。如果你没有这么高要求的体积云,其实可以看看我这篇(的下一篇)
上文我们分析了天空的渲染部分,现在我们来详细说明体积云的渲染过程,这部分内容比较复杂,本来想再分上下篇,但是这篇拖太久了,下面还有一部分新的关于体积云的内容,想了想还是合一块了。
云建模
ClayJohn的实现中,云建模基本是完全参考《地平线:零之曙光》的Nubis体积云方案,是一个经典解法。
实际要做的事情就是提供一个density()函数。通过这个函数能够取到空间中任意位置的云的密度。用以做光线步进或者什么别的事情。
首先,云被限制在两个同心球壳之间
1 | const float g_radius = 6000000.0; // 地面 |
通过这个函数去取某个坐标在云层范围里的相对高度:
1 | float GetHeightFractionForPoint(float inPosition) { |
建模过程用到了三张噪声纹理,perlworlnoise.tga作为large_scale_noise使用。它的R通道作为主轮廓,GBA是多频FBM噪声,是3D纹理。worlnoise.bmp作为small_scale_noise,是多频 Worley灶神。还有一张weather.bmp作为天气噪声,用于采样获得云类型和云覆盖度的系数,这个是2D噪声。三张图预览效果如下:



这几张有点眼熟🤣,就先不考据出处了。
实现里对云的形状进行了区分,三种云定义了三种不同的云垂直梯度。在mixGradients函数里计算它。
1 | const vec4 STRATUS_GRADIENT = vec4(0.02, 0.05, 0.09, 0.11); // 层云:贴底很薄 |
每个 vec4 是四个高度阈值 (x,y,z,w),定义一条”梯形”曲线的四个拐点。cloudType(来自天气图 R 通道)在 0~1 之间,用三角权重在三种梯度间插值:
1 | float stratus = 1.0 - clamp(cloudType * 2.0, 0.0, 1.0); // type=0 时为 1 |
其中,cloudType=0 代表层云,cloudType=0.5 代表层积云,cloudType=1 代表积。
之后会把梯度转换为密度乘子
1 | float densityHeightGradient(float heightFrac, float cloudType) { |
说白了,这个步骤其实就是按高度计算出一个密度的比例值。云层两边薄中间厚的效果就是靠这个得来的。
最后将所有组件组合起来,得到density()函数。
先取高度分数
1 | vec3 p = pip; |
施加基础风力让云慢慢平移
1 | p.xz += params.time * 20.0 * normalize(params.wind_direction) * params.wind_speed * 0.6; |
采样前文所说的large_scale_noise来获得轮廓
1 | vec4 n = textureLod(large_scale_noise, p * 0.00008 * noise_scales.x + offset, mip - 2.0); |
然后用梯度+Noise+WeatherMap来获得最终的云。
1 | float g = densityHeightGradient(height_fraction, weather.r); // 垂直窗 |
之后再整点更细节的小风
1 | p.xz -= params.time * normalize(params.wind_direction) * 40.; |
再用小噪声做边缘侵蚀
1 | vec3 hn = textureLod(small_scale_noise, p * 0.001 * noise_scales.y + offset, mip).rgb; |
最后用pow进行一个塑形,让底部实一点,顶部虚一点。
1 | return pow(clamp(base_cloud, 0.0, 1.0), (1.0 - height_fraction)*0.8 + 0.5); |
这样整个密度部分就完成了。
光照计算
光照部分其实是要解体积渲染方程。沿一条视线,进入眼睛的光是沿途每一点散射进来的光、再被前面的云衰减后的累加。
$$
L = \int_0^D \underbrace{T(0 \to s)}{\text{视线透射率}}; \underbrace{\sigma_s(s)}{\text{散射}}; \underbrace{L_{\text{in}}(s)}_{\text{该点收到的光}}, ds
$$
其中视线透射率按 Beer-Lambert计算,即
$$
T(0\to s) = \exp!\big(-\int_0^s \sigma_t,dx\big)
$$
march()的全部工作就是离散算积分。
在开始循环前,需要进行一些准备工作。为了消除离散步长产生的条带问题,可以通过添加抖动来解决这个问题。
1 | float ss = length(dir); // 步长(dir 传进来时带长度) |
不过需要说明的是,ClayJohn自己的实现里没有这部分,由于这个实现的特殊性,你不加这玩意也看不太出来。我自己为了一些特殊情景的观感还是加上了。
用于计算自阴影的光线步长。
1 | float t_dist = sky_t_radius() - sky_b_radius(); // 云层厚度 2500m |
注意这个和视线出发的步长不同,这个是计算朝太阳方向走被上方的云挡了多少光用的。
云是强前向散射介质,即光大多继续沿原方向走,所以背光看云边会发亮。用 Henyey-Greenstein 相位函数来处理这个事情:
1 | float henyey_greenstein(float cos_theta, float g) { |
g>0 就是前向。
这边使用双瓣HG,一个是上文所说的强前向,另一个做后向的,用于实现那种“背光轮廓光”的效果。
1 | float dual_lobe_hg(float cos_theta, float g_forward, float g_back, float w) { |
纯为了好看,做了个银边效果的增强。这个效果是太阳/月亮附近那一圈的云的边缘亮度会增加。
1 | float silver_weight = 0.5 + 0.5 * smoothstep(0.0, 1.0, -costheta); |
这两部分都是我自己往ClayJohn的实现上加的。他的实现看上取云不论什么时候都有点灰灰的,所以我做了这些增强。
之后就是从skyLUT取光的颜色。
1 | vec3 atmosphere_sun = getValFromSkyLUT(LIGHT_DIRECTION) * 0.1 * LIGHT_ENERGY * LIGHT_COLOR; // 太阳方向的天空色 = 直射光色 |
所以这个云实现和天空基本是强制耦合的。
下面就是主循环部分,老生常谈的沿着视线方向做RayMarch。
1 | for (int i = 0; i < depth; i++) { |
自阴影部分即每个点也要朝着光源方向采样一定步数。
1 | vec3 blend_ldir = mix(ldir, moon_dir, night_blend); // 昼夜混合的光向 |
注意到有RANDOM_VECTORS[j] * j,这说明越往光源走,偏移越大。这是因为采样点呈锥形发散而非一条直线。模拟了光的散射锥,比直线采样自阴影更软。除此之外,density(lp, lweather, float(j))可以看出 mip 随 j 增大用于省性能。
除此之外,还进行了一次粗略的远处阴影采样。
1 | lp = p + blend_ldir * 18.0 * lss; // 朝光源跳很远(18 步) |
在这之后,就是通过 Beer-Powder 与多重散射来计算云本身。Beer指的是 exp(-density·cd) 的做法,即光被遮挡越多越暗。Powder 即1 - exp(-density·cd·2),是个经验补偿,模拟云朝光面边缘反而偏暗的暗边效应。
单次散射会让云的中间调很灰。真实云里光弹很多次,从内部漏出来,把灰色提亮。这里用 3 个倍频近似,是Wrenninge/Frostbite的做法。
1 | const int MS_OCTAVES = 3; |
最后就是环境光和散射累加。
1 | vec3 ambient = mix(ground_light, ambient_light, smoothstep(0.0, 1.0, height_fraction)); |
按云层厚度施加一个底部到顶部的环境光颜色。
不过March的步数不是固定的,由调用方根据视线穿过云层的实际厚度决定。
1 | float shelldist = interval.y - interval.x; // 视线在云层里的长度 |
接近地平线的地方多给点步数,正上方的区域就少给点步数。
这样云的渲染部分就完成了,还是个比较标准的实现。下面我们具体说一下这个实现的神奇Trick。
邪道大合集
所谓八面体贴图,其实只是球体投影的一个方式。完整的球面投影方式如图所示:

不过对于我们的实现,其实还能进一步节约。考虑到云只会生成在天空的上半球面,我们可以只取图中的上半球,然后可以发现投影图就只生下了一个菱形。我们将它旋转45度就能变成一个正方形了。
这个实现做的事情就是把云的渲染结果写入这个正方形作为贴图,然后Sky Shader去采样这个贴图,将云合成到天空上。所以说这个实现本质上和实际渲染分辨率是解耦的,性能也非常容易调整。
着色器里是这么进行编码和解码的:
1 | vec2 vec3_to_oct(vec3 e) { |
单纯这样做,似乎节约不了很多性能。于是这个做法用了一个更狠的Trick去完成这个事情:分块更新这个八面体贴图。
首先,ClayJohn给这个八面体贴图设置的默认分辨率是768 * 768,也就是说,它本身每帧全跑也就大约是一个1080P半分辨率全屏RayMarch的性能水平。但考虑到云本身不是什么太高频的信息,我们可以用更激进的方式去均分掉这个计算量。
做法如下:将768 * 768的贴图拆分成 8 * 8 的块,每块是96 * 96的分辨率,然后每帧更新一块即可。64帧能够把所有块更新一次。但很明显,如果真的这么做的话云层贴图本身会出现明显的切割痕迹。所以实际操作是三张图进行轮转。
1 | var texture_to_update : int = 0 # 正在后台逐块写 |
每次完成一整轮更新就轮转这个贴图索引。
1 | if frame >= frames_to_update: |
最后,在skyShader里按时间进行两张图片的慢慢混合。
这样,实际每帧运算量直接降低到了 1/64,最后得到了这个快到爆炸的邪道体积云实现。
不过,这个实现明显的缺点就是云层移动过快的时候能明显观察到64帧内的混合痕迹,而且视角实际上看不到部分的云的计算按理说是被浪费掉了。不过也非常快了。但是这带来一个额外的好处,由于渲染本身是跟视角完全解耦的,不会因为视角移动产生任何传统实现可能有的重投影降噪导致的拖影现象。

不需要降噪😨
也许你可以注意到,这套实现不存在任何降噪或者类似降噪性质的管线,但是最后的结果也非常漂亮,没有任何噪声。这主要有两个原因
由于计算量得到了巨大程度的分摊,你可以肆无忌惮的提高采样和March质量本身。默认的March步数就是32~192步,已经是个不一定低的步数了。
在两帧图像之间缓慢混合,其实本身也起到了一定的降噪效果。而且它不会每帧重新渲染图像,所以本来就不会出现高频的抖动现象。
如果你没有穿过云层的要求的话,这样一个体积云就在Godot里面实现了。我想这也能作为Godot其实已经不非常缺乏图形能力的一个证明。不过我也完成了一个标准的体积云实现,这个就留着下次再讲了😋

(补充:这个是以SunshineCloudsV2插件为脚手架搭建的。这个插件因为它构式一样的优化和降噪其实基本不可用,我以这个为基础设施做了大量优化改进)