环游世界,第 34 篇:漂亮的天空

多次散射、间接光、地平线弯曲和色调映射,如何把一款游戏里平淡的天空变成以假乱真的日落。

中文
复制
低多边形风格的湖上场景:一只棕色小帆船停在深蓝色水面上,左侧是灰色岩壁,远山泛白,太阳低悬在右侧地平线上,水面拉出一道金色反光,云朵点缀着蓝天

在上一篇文章里,我把游戏中的云调到了勉强能看的效果。但试玩时再看,海面、天空和云放在一起的整体观感,我还是不满意。做了一些小调整之后,画面开始好看多了。

云中的光散射

上一篇结尾我用了一张 povray 渲染出来的查找表,里面记录的是云向外散射的光量,横轴是光线角度,纵轴是云的密度:

使用旧查找表渲染的密集球体网格

我对这张表的不满,之前已经写过了:

我开始怀疑 povray 根本没有模拟多重散射;文档里既没说有,也没说没有。不管我把采样数和区间数设成多少,球体底部始终亮不起来。

我又一次动了念头,想自己写一个递归光线追踪器来验证这个猜测,但还是忍住了 […]

最后好奇心还是占了上风,我真的用几百行 Rust 写了一个多线程递归蒙特卡洛光线追踪器。它极其简陋,而且是针对这个具体情况硬编码的,所以只能渲染一团密度均匀的完美球形云。但这已经足够确认:povray 确实不模拟多重散射。考虑多重散射后,查找表的样子差别很大:

使用新查找表的相同球体网格

这个对比并不完全对等,因为我还调整了一些参数和坐标轴的缩放。但总体效果很清楚:从云中透出的光分布得均匀多了,云整体看起来也更亮。

在低密度下(薄云),差异不太明显,查找表最上面几行几乎一样。毕竟密度低的时候,光线没有多少机会被散射一次以上。所以我们来对比更高密度下的效果,也就是更厚的云层。下面是用旧 LUT 渲染的一些云:

带有旧版 LUT 的云

换成新表之后:

使用新查找表渲染的云

上一篇文章里提到的“金属感”已经没有了;云底更亮,整体光照也更平滑。

一个奇怪的小技巧

现实中,云并不只被太阳直射照亮。它们还会接收并散射来自天空的间接阳光。这种光通常偏蓝,所以云也应该带上更蓝的色调。

我不想直接加一个固定的“蓝色”项,因为那只有在白天才正确;我们希望云在日出、日落或夜晚也能跟随天空当前的颜色。在 Godot 里该怎么做?

答案其实很简单,但需要先理解 Godot 的光照渲染是怎么工作的。除了我们已经处理的直接光,Godot 还会考虑来自环境的间接光。这一切都在底层自动完成,用户无法控制,所以我的理解比较模糊,但足够用了。

据我所知,间接光是从一两张立方体贴图中采样的,这些贴图包含来自各个方向的辐射亮度和辐照度。(我知道辐射亮度和辐照度是两回事,但并没有完全理解它们的区别。)对于云这种漫反射材质,我预计会沿表面法线的方向采样这张立方体贴图(这些立方体贴图?),因为法线方向代表了大部分间接光的来向。

在云的底部,法线朝下指向地面,所以这里渲染出的云几乎没有间接光。现实中它确实会接收到间接光,只不过来自上方:光只是穿过了云层被散射。那么,如果我们把法线改成朝上指向天空,会发生什么?效果很细微,但很重要;下面是修改前:

底部呈深灰色的云

修改之后:

底部呈浅蓝色的云朵

云确实带上了蓝色调,和天空融合得更好,整体看起来更柔和、更遥远,云本来就该是这样。我很喜欢这个效果!

那日落时分呢?那时天空并不是近乎均匀的蓝色。下面是修改前:

没有做法线弯曲的日落

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

做法线弯曲后的日落

云变粉了!我觉得这更接近真实日落的样子;粉色应该是红光(直射阳光)和蓝光(天空的间接光)混合出来的,绿通道在中间多少被冲淡了。

大气散射

另一件让我在意的事,是天空靠近地平线的地方黄得不自然。太阳低垂时这样没问题,也符合预期,但太阳正当天顶时也会出现,比如这里:

蔚蓝的天空在地平线附近渐变为黄色

我怀疑是光线步进时外散射施加得比内散射多,于是试了各种 hack,比如加一个假的多次内散射项。最后真正有效的做法是调整光线步进中各操作的顺序。原来是:

  1. 把散射进光线段的(偏蓝)光加进去
  2. 按光线段上的外散射做衰减(偏红)

……我把顺序反了过来:

  1. 先按光线段上的外散射做衰减(偏红)
  2. 再把散射进光线段的(偏蓝)光加进去

效果是沿途的衰减比内散射少,画面明显好多了:

蔚蓝的天空在地平线附近渐渐泛白

理论上这应该是数值积分方法带来的误差,增加积分步数后差异应当消失。但事实并非如此,所以我怀疑某处仍然有问题。不过眼下这样也够用,希望以后也一直够用。

地平线弯曲

要渲染出真实的天空,我必须使用真实地球的参数,比如大气密度、高度和行星尺寸。但我游戏里的行星比地球小约 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 才能实现“照片级渲染”,而那个值只会让一切看起来更暗。

调整色调映射是件棘手的事,因为对色调映射器的改动会影响之前的所有环节。所以实际操作中,这个过程并不像我在这里写的那样线性,需要在调整色调映射器和场景光照之间来回反复。比如,我直接把天空的亮度降低了一半,这在色调映射之后带来了好得多的对比度。

太阳系仪

如果想要一个像样的昼夜循环,就需要太阳升起和落下。而如果想让我们的行星感觉像地球,日出和日落的时间就应该取决于位置和季节。这种依赖关系源于地球绕太阳公转和绕自身轴自转。月球在天空中的运动以及月相也是类似的道理。

要把这些计算全部正确地算出来,似乎很复杂。所以我选了一条更简单的路:在引擎里把整个情况建模出来,让行星和它的卫星像太阳系仪里那样旋转:

引擎中的太阳系仪行星及其卫星在引擎中旋转,用于计算日出和日落时间。

然后我让引擎反推逆变换,确定从地表某个观测者的视角看,天体在天空中的位置。效果不错,渲染出来的太阳系仪也成了个顺手的调试工具。

折腾完这些,我欠你一段日出视频,对吧?这次还得在游戏里找个风景好的地方拍!

日出时的风景名胜太阳从游戏中的一处风景名胜升起。

来源: Frozen Fractal← 返回首页