前言


此天空实现大量参考了Godot源码主要贡献者ClayJohn在一个开源Repo里的实现,并作出了大量修改和扩展。本文会详细解析他的实现并说明我自己做了什么样的内容以及扩展。
现在的天空包含完整的基于物理的昼夜循环,以及高质量(几乎无噪音)的体积云渲染,且速度极快。
这套实现天空部分比较标准,但是体积云部分的渲染策略非常罕见,性能比起3A游戏的普遍标准是数量级级别的好(ClayJohn自己的代码就是这种实现,我基本抄了),但是也有相当的局限性(我通过一些Trick进行了改进,但是治标不治本),是一个需要取舍的工程问题。
如果你需要一个标准的3A级别的体积云实现,你可以使用GodotAssetLib里面的SunshineClouds这个插件。但是这个插件性能问题不小,且云的降噪写的质量比较差,实际视觉效果可能不理想。如果你没有这么高要求的体积云,其实可以看看我这篇(的下一篇)
整套天空渲染架构有四级渲染管线,这里我们一一分析。由于篇幅所限,体积云会在下一部分详细分析。
天空,透射率LUT
整个大气渲染,其实就是在计算光从大气里的A点到B点还剩多少比例的问题。由于这个量只依赖于两个量,所以可以预计算成一张二维查找表,也就是这里的透射率LUT。计算好之后直接用采样纹理的方式取即可。
光沿路径衰减遵循 Beer–Lambert 定律。设 $\sigma_t(h)$ 是高度 $h$ 处的消光系数(extinction coefficient),单位 $\text{km}^{-1}$,表示每走 1 km 损失的比例。那么沿路径 $A\to B$ 的透射率是消光系数的路径积分再取负指数:
$$
T(A \to B) = \exp!\left(-\int_{A}^{B} \sigma_t\big(h(s)\big), ds\right)
$$
所以ClayJohn的天空实现里的transmittance-lut.glsl实际就是在做这个积分。
透射率原本依赖:起点位置(3D)+ 方向(2D)+ 终点。看似维度很高。但是在球对称大气假设下可以大量简化。可以认为起点位置只和高度有关。这样起点就简化为了一维参数(高度)。假设光只来自于无穷远的太阳,我们就能把终点固定在大气顶。这样方向也就只剩下了一个角度:就是太阳到水平平面角度。
这样就把输入参数简化为了两个变量,从而让查找表做法成为可能。
在实际的操作过程中,LUT 被渲染为了 256 * 64 的图像。UV的x分量是太阳角度的余弦值。UV的y分量是海拔。
除此之外,ClayJohn的实现还有个Trick(可能是更好的方式,并非Trick),就是所有波长相关量都是 vec4,分别对应 630 / 560 / 490 / 430 nm的波长,计算完之后再转换为sRGB。这应该能带来很明显的视觉准确度提升。
在实际的glsl计算中,get_atmosphere_collision_coefficients()这个函数包含了经典的Rayleigh,Mie和臭氧吸收计算。
Rayleigh
get_molecular_scattering_coefficient()中是这么写的
1 | return molecular_scattering_coefficient_base * exp(-0.07771971 * pow(h, 1.16364243)); |
其中,molecular_scattering_coefficient_base = vec4(6.605e-3, 1.067e-2, 1.842e-2, 3.156e-2)。
蓝光(最后一个分量 3.156e-2)系数是红光(6.605e-3)的近5倍,因为蓝光被散射得多,所以天是蓝的。exp(-0.07… * pow(h, 1.16…)) 是对真实大气密度随高度衰减的经验拟合,比简单的指数大气更准确一点。
Mie
Mie主要实现雾霾和地平线泛白的效果。写在get_aerosol_density()函数。
1 | return aerosol_base_density * (exp(-h / aerosol_height_scale) + aerosol_background_divided_by_base_density) |
参数来自 Guimera et al. (2018) 的物理大气模型。
臭氧吸收
这个效果是让天空顶部更蓝。写在get_molecular_absorption_coefficient()函数
1 | h += 1e-4; |
h加了一点防止数值问题。
完成
之后就是RayMarch,并把积分结果写进查找表里。ClayJohn的实现里,这个March了40个Steps。由于只要在开头进行一次计算,所以步数多点也无所谓。
最后计算出的LUT大约是这样的。

我们看一下这个LUT。明确两点:
从左到右依次代表太阳在地平线(最低)和太阳在顶部(最高)。纵轴代表海拔,越往上越高。
这张纹理是 RGBA16F,存的是 4 个波长的透射率(630/560/490/430nm),不是 RGB+透明度。第 4 个波长 430nm 正好落在 alpha 通道。不过Godot的预览就这样了我也没招了。
主要中间有一条蓝紫色的窄带,是物理正确的表现,说明臭氧项的作用正确。
sky-view LUT,绘制结果
下面就说明sky-view LUT,以及如何把结果绘制成天空结果。
Sky-view LUT更进一步,其结果是为了说明朝天空某个方向看过去,沿这条视线累积进眼睛的散射光总共是什么颜色。它把整个半球(其实是全方向)的天空颜色预计算成一张 200×100 的小图。运行时无论是云着色器还是天空着色器都查询这个纹理就行了,非常便宜。
实际计算过程在sky-lut.glsl里,把像素映射成一条视线方向:
1 | vec2 uv = vec2(pos) / params.texture_size; |
可以注意到,仰角用了非线性采样 l*l*sign(l) ,这是因为天空颜色在地平线附近变化最剧烈(回想一下夕阳和日出的效果),而天顶方向几乎是均匀的颜色。这样设计的目的是把更多的纹理密度给到地平线附近,防止地平线附近的纹理密度不够,最后出现明显的Artifact(比如产生条带)
当然了,采样的时候必须还原这个变换。
之后这个Compute Shader就是计算内散射积分。沿视线 Ray-March 30 步,每步把”散射进来的光”累加,同时维护视线自身的透射率。
实际计算每个位置太阳到这个位置光线的结果的时候,就直接查前文的LUT就行了。(当然,按理来说你也可以在这里头直接计算,但是😅)
到达该点的阳光被散射系数和相位函数重新分配方向,其中朝向视线的那部分加进S。两种成分(Rayleigh,Mie)分别算再相加。
除此之外还做了一个能量守恒积分。
1 | S_int = (S - S·step_transmittance) / extinction |
这是引用自 Hillaire/Frostbite 的实现(太有学术规范了)。
除此之外,还做了多重散射近似。真实大气里光会弹很多次。用两项廉价的近似补上:
1 | // 地面二次反照 |
整个积分都在 4 波长(630/560/490/430nm) 上进行,上文已经说了。最后用 4×3 矩阵转回可显示的 linear sRGB。不过储存的是高动态范围的结果,在实际采样的时候会用一个经验参数除回去到正常的颜色空间。
之后天空Shader去采样SkyLUT即可。太阳和月亮是天空Shader负责绘制,这部分比较简单就不再叙述了。
最后(某个时段)的天空LUT是这样的:

下篇会详细介绍体积云的实现,是文章的重点。我们下次再见~