环游世界,第 34 篇:漂亮的天空
多次散射、间接光、地平线弯曲和色调映射,如何把一款游戏里平淡的天空变成以假乱真的日落。
中文
复制

在上一篇文章里,我把游戏中的云调到了勉强能看的效果。但试玩时再看,海面、天空和云放在一起的整体观感,我还是不满意。做了一些小调整之后,画面开始好看多了。
云中的光散射
上一篇结尾我用了一张 povray 渲染出来的查找表,里面记录的是云向外散射的光量,横轴是光线角度,纵轴是云的密度:

我对这张表的不满,之前已经写过了:
我开始怀疑 povray 根本没有模拟多重散射;文档里既没说有,也没说没有。不管我把采样数和区间数设成多少,球体底部始终亮不起来。
我又一次动了念头,想自己写一个递归光线追踪器来验证这个猜测,但还是忍住了 […]
最后好奇心还是占了上风,我真的用几百行 Rust 写了一个多线程递归蒙特卡洛光线追踪器。它极其简陋,而且是针对这个具体情况硬编码的,所以只能渲染一团密度均匀的完美球形云。但这已经足够确认:povray 确实不模拟多重散射。考虑多重散射后,查找表的样子差别很大:

这个对比并不完全对等,因为我还调整了一些参数和坐标轴的缩放。但总体效果很清楚:从云中透出的光分布得均匀多了,云整体看起来也更亮。
在低密度下(薄云),差异不太明显,查找表最上面几行几乎一样。毕竟密度低的时候,光线没有多少机会被散射一次以上。所以我们来对比更高密度下的效果,也就是更厚的云层。下面是用旧 LUT 渲染的一些云:

换成新表之后:

上一篇文章里提到的“金属感”已经没有了;云底更亮,整体光照也更平滑。
一个奇怪的小技巧
现实中,云并不只被太阳直射照亮。它们还会接收并散射来自天空的间接阳光。这种光通常偏蓝,所以云也应该带上更蓝的色调。
我不想直接加一个固定的“蓝色”项,因为那只有在白天才正确;我们希望云在日出、日落或夜晚也能跟随天空当前的颜色。在 Godot 里该怎么做?
答案其实很简单,但需要先理解 Godot 的光照渲染是怎么工作的。除了我们已经处理的直接光,Godot 还会考虑来自环境的间接光。这一切都在底层自动完成,用户无法控制,所以我的理解比较模糊,但足够用了。
据我所知,间接光是从一两张立方体贴图中采样的,这些贴图包含来自各个方向的辐射亮度和辐照度。(我知道辐射亮度和辐照度是两回事,但并没有完全理解它们的区别。)对于云这种漫反射材质,我预计会沿表面法线的方向采样这张立方体贴图(这些立方体贴图?),因为法线方向代表了大部分间接光的来向。
在云的底部,法线朝下指向地面,所以这里渲染出的云几乎没有间接光。现实中它确实会接收到间接光,只不过来自上方:光只是穿过了云层被散射。那么,如果我们把法线改成朝上指向天空,会发生什么?效果很细微,但很重要;下面是修改前:

修改之后:

云确实带上了蓝色调,和天空融合得更好,整体看起来更柔和、更遥远,云本来就该是这样。我很喜欢这个效果!
那日落时分呢?那时天空并不是近乎均匀的蓝色。下面是修改前:

云层底部被染成橙色,因为入射的直射阳光在强烈的瑞利外散射下变成了橙色。下面是同一场日落,只是调整了法线:

云变粉了!我觉得这更接近真实日落的样子;粉色应该是红光(直射阳光)和蓝光(天空的间接光)混合出来的,绿通道在中间多少被冲淡了。
大气散射
另一件让我在意的事,是天空靠近地平线的地方黄得不自然。太阳低垂时这样没问题,也符合预期,但太阳正当天顶时也会出现,比如这里:

我怀疑是光线步进时外散射施加得比内散射多,于是试了各种 hack,比如加一个假的多次内散射项。最后真正有效的做法是调整光线步进中各操作的顺序。原来是:
- 把散射进光线段的(偏蓝)光加进去
- 按光线段上的外散射做衰减(偏红)
……我把顺序反了过来:
- 先按光线段上的外散射做衰减(偏红)
- 再把散射进光线段的(偏蓝)光加进去
效果是沿途的衰减比内散射少,画面明显好多了:

理论上这应该是数值积分方法带来的误差,增加积分步数后差异应当消失。但事实并非如此,所以我怀疑某处仍然有问题。不过眼下这样也够用,希望以后也一直够用。
地平线弯曲
要渲染出真实的天空,我必须使用真实地球的参数,比如大气密度、高度和行星尺寸。但我游戏里的行星比地球小约 75 倍,因此曲率更大。于是当我们用地球参数来渲染天空时,天空的地平线会落在真实地平线之上。在两条地平线之间,天空着色器会假装大气密度继续朝行星中心指数增长,从而产生一条暗带,在日出和日落时尤其明显:

为了让两个地平线对齐,我对天空着色器做了一个简单的 hack:给全局视线向量的 y(垂直)分量加上一个小值,然后重新归一化。效果是把整个天空往下拉,在地平线附近拉得最多,于是那条暗带就消失在了真正的地平线后面:

这招有点糙,但便宜又管用。
色调映射
唉,每个现代 3D 游戏美术的噩梦。如今游戏都以 HDR(高动态范围)渲染,颜色值可以超过 1.0 的上限。直接放到普通显示器上显示,超出的部分只能被截断,结果就是一块块过饱和的颜色:

要解决这个问题,通常会用某种 tone mapping 公式把 HDR 值映射回 LDR 空间再显示。Godot 实现了几种 tone mapper;我觉得 Reinhard 太暗,ACES 和 AgX 又太灰。最后我选了 Filmic tone mapper,白点设为 2.0:

尤其是云层,看起来柔和好看,在远处也更好地融入远景。
白点带来的差别依然很细微,因为 Godot 自己建议至少 6.0 才能实现“照片级渲染”,而那个值只会让一切看起来更暗。
调整色调映射是件棘手的事,因为对色调映射器的改动会影响之前的所有环节。所以实际操作中,这个过程并不像我在这里写的那样线性,需要在调整色调映射器和场景光照之间来回反复。比如,我直接把天空的亮度降低了一半,这在色调映射之后带来了好得多的对比度。
太阳系仪
如果想要一个像样的昼夜循环,就需要太阳升起和落下。而如果想让我们的行星感觉像地球,日出和日落的时间就应该取决于位置和季节。这种依赖关系源于地球绕太阳公转和绕自身轴自转。月球在天空中的运动以及月相也是类似的道理。
要把这些计算全部正确地算出来,似乎很复杂。所以我选了一条更简单的路:在引擎里把整个情况建模出来,让行星和它的卫星像太阳系仪里那样旋转:
然后我让引擎反推逆变换,确定从地表某个观测者的视角看,天体在天空中的位置。效果不错,渲染出来的太阳系仪也成了个顺手的调试工具。
折腾完这些,我欠你一段日出视频,对吧?这次还得在游戏里找个风景好的地方拍!