|
暴雨、狂风、雷电,相信大家都十分熟悉现实中的这些天气现象。这些现象体现了大自然的威能,给人极大的感官冲击。在如今的端游大作中,我们经常能感受到游戏制作组在这方面下的大功夫,然而在手游上,由于手机的组件的功效远低于PC端,如何平衡效能并充分利用手机上的设备来进行模拟渲染,就成为了开发者重要的研究课题。
在由腾讯游戏学堂举办的TGDC2022腾讯游戏开发者大会上,腾讯互娱魔方工作室群引擎中心专家工程师陈家铭以手游《暗区突围》为例,向大家展示了如何通技术渲染优化手段使得手游获得了只有主机游戏才能享受的特性。
以下是演讲实录:
大家好我是魔方引擎中心的技术专家陈家铭,很荣幸今年又可以在TGDC分享自己的工作成果,这一次要和大家分享的是手游《暗区突围》里的动态天气渲染技术。
首先介绍一下我所属于的Studio魔方工作室群它成立于2010年是腾讯IEG四大游戏工作室群之一,魔方包括了魔术师、魔镜、魔王还有我所在的技术中心,我们拥有多个全球顶级的IP项目,包括了知名的《火影忍者》《航海王》《一人之下》《秦时明月世界》。还有自研的IP,包括《暗区突围》《洛克王国》《王牌战士》等等。
《暗区突围》是一款拟真的第一人称射击游戏,为了让玩家有更深的代入感,策划希望把以前只有主机游戏才能享受的特性都放在这款手游里面,也包括了今天的我要讲的主题,动态天气效果和体积云等效果。
上图是今天的内容大纲,首先我会讲讲大气产生的基础原理,以及我们在放到手里面最后的一些相关定制以及优化,之后会讲体积云的渲染系统,还有里面的一些技术细节,最后是一些相关的天气效果的分享。我们先从天空大气开始。
大家可能会问为什么我们手游的天空不能简单地用曲线来定义天空的颜色就好,而要搞很复杂的大气呈现呢?其实天空的颜色是千变万化的,会受到时间、地理位置、天气和污染等因素所影响。举个例子,同样是黄昏有可能这像左边这一张图,天空大部分的颜色都是蓝色,而只有接近地平线的才呈现橙色,也有可能像右边的图片,整个天空都是偏红色的。所以在写实的游戏里面,如果要模拟各种的天气变化,单单只是以曲线去做这个模拟,它的组合性是爆炸的,很难去编辑和维护。因此我们就会通过一个物理的方法去计算,那么我们是如何计算呢?其实大气是由不同的大小的粒子所组成的,天空的颜色都是由这些粒子对太阳光所构成的散射现象所确定的。为了渲染出大气散射的效果,我们一般都需要在视点以光线行进来计算每个方向所得到的颜色。
比如说在大气层的A点方向到B点,我们就需要在这条射线上的位置,P0, P1, P2 利用相位函数算出所接受到的散射,然后呢再计算他们的积,那就是这个方向可以看到天空颜色的结果。然而这个方法其实在游戏里面实时算是很难实现的, 因为每个方向都可能要考虑到几十到上百个采样点。
我们再看看虚幻引擎里面是怎么解决这个性能问题的。它利用了一系列的查找表来减少计算量,具体而言,虚幻引擎在每一帧里面可以使用这个计算着色器来生成4个基本的查找表分别为:透光度的查找表,它记录了光线在场景里面的传递情况。另外一个是多重散射的查找表,就是用来快速算出多重散射的结果;还有一个是天空图示的查找表,它是基于透光度还有多重散射两个表格,预先算好一个天空颜色;以及我们最终会有一张用来算天空透视效果的一个3D纹理表。
虚幻引擎的方案不单是基于物理的,对美术也挺友好,在中高端的移动设备上也有良好的性能。可是在比较老的手机上,因为它们的带宽非常有限也负担不起每帧计算这么多的查找表,所以我们对它做了一些优化。
首先我们会舍弃了空气透视的数据,就改成用高度雾来代替它的效果,这样的话我们场景里的shader也可以节省一次的3D纹理的采样,同时呢,舍弃了这一部分的数据之后,所剩下的查找表都是二维的,因此我们可以利用上述着色器来更新它。
这是非常重要的,因为有许多的移动设备依然对这个计算着色器的支持不太完善。由于我们游戏里面的局内时间变化比较慢,所以呢我们也可以分帧去计算每个LUT。就是说,我们每帧只计算里面的一部分的像素,这样的话在低端的移动设备上也可以承受。
为了做进一步的优化,我们也将天空图示的查找表改用了半八面体的参数化,同时也丢弃了地平线以下的内容。这不但节省了50%的光线行进的计算,也避免了查找这个结果的时候,需要调用一个平方根指令的步骤。做了以上优化之后我们发现其实也可以把这个(shader)移植到CPU里面去计算,而每帧的耗时大概是0.5ms左右吧,所以呢我们预留了这个方法在最老的手机上去使用。
这是原始版本以及经过我们优化版本之后,在一天里面三个不同时间段的一个比较。我们可以看到其实在太阳周围稍微有一点点的偏差,但是在游戏中其实是不容易观测出来的。
我们再看看优化之后天空渲染的性能,就从原始的1.35毫秒就降到了优化之后的0.78毫秒。所以,我们用半八面体投影之后节省了大概是40%的GPU耗时,同时性能也有一个明显的提升。
接下来我会继续分享体积云的处理。
有同学也可能会问,既然是手游,为什么不可以用带法线贴图的面片云?
而要搞体积云呢?我们主要有两个原因,第一 ,我们刚开始的时候我们其实也尝试过面片云这个方案的,但美术觉得不太能表现他们要求的体积感,而且面片云也比较难去模拟多重散射独有的光照特性。第二个是面片云一般都是预先烘焙的,而我们的游戏要求在局内的天气有一个实时的变化,云的密度也会跟随着天气所改变的,面片云就比较难去支持这样的效果。
所以我们的方案参考了《地平线:零之曙光》团队在2015年GDC上分享的案例,我在这里做一个快速的回顾。当我们要从一个固定的方向望向云层的时候,云的颜色是从这个方向所散射而来的阳光和环境光所确定的。我们一般只考虑地面上距离2.4到6公里之间的云层。因此我们在这两个高度之间以光线行进来计算出这个方向而来的一个散射的亮度。
首先我们会在这条射线上平均分布一些采样点,然后计算出每个点上我们所可以接受的一个光照,之后呢我们会根据云层的密度算出该点有多少个光可以散射到相机里面,再把所有散射过来的光线能量相加就可以得到云从这个方向散射而来的颜色。
但是我们还有三个疑问:
第一是云层的密度是怎么去定义呢?
第二个就是采样点所接受到的光照到底有多少,如何给散射?
第三个是怎么可以把这一个计算的性能优化到可以在手机上去跑?
我们后面会一个一个展开来讨论。
首先是云的建模,就是怎么去定义云层的密度。我们会使用Worley噪声生成3D纹理,然后呢让它平铺在天空中,这样就可以定义出基础的云的密度。我们使用了基础和细节两层的噪声,最终的结果都是把基础的噪声再减去细节所得到的结果。细节的噪声会平铺更多的次数,这样的话,我们就不需要一个特别高的分辨率就可以获得足够的细节。
但是仅仅是这样的话也不可以创造出整个覆盖天空的云层。所以我们引入了一张称为Weather Map的贴图,这样美术可以控制不同天气状态之下,云的形状以及分布。它其实是一张正交投影的2D纹理,覆盖了大概是地面40公里的一个范围。Weather Map的R通道是云的覆盖率,这代表它的数字越高云的密度也等于越高,G通道是用来定义云的种类。此外我们还有一张称为Cloud profile的2D纹理,主要是用来模拟云在不同高度有不同形态的一个特性。
刚才提到,我们是通过动态生成的Weather Map来控制云层随着天气的变化。在我们的系统里面,Weather Map是由Cloud Mask所组成的。美术可以在地图中摆放的各种的Cloud Mask,就像右边的动图所展示的一样。每个Cloud Mask都有一个材质来定义它所绘制到Weather Map里面的内容,例如当材质输出一个白色的圆形的一个特效的时候,对应的云层就会变得更密集。
我们也可以输出黑色则该位置的云将被擦除。为了方便控制不同天气下的云层我们有两个全局的参数,一个是全局的云的覆盖率,一个是全局的云的形态。这两个值我们作为材质输入传递给Cloud Mask并且进行内容的绘制。
一般情况下,我们会以Worley噪声生成一个很大的Cloud Mask来定义基础的密度,之后再补一些小的mask作为局部的调优。
在进一步讲解云的光照之前,我先介绍一下光吸收的物理定律:比尔-朗伯定律。假如阳光的亮度是1.0,当它经过云层照射到采样点的时候,采样点所接受到的亮度是多少呢?而最终能到达相机的又有多少呢?要回答这个问题,我们首先要算出阳光到达采样点,以及采样点到达相机的透光度Transmittance。
根据这个比尔·朗伯定律,透光度是由光线的光学深度Optica Depth所计算的,而这个光学深度就是从这个路径上云层的密度的积所得到的,单次散射其实就是一般通过太阳光阴影和三维函数相乘出来的一个结果,我们是用了四个样本来评估太阳方向的光学深度,所计算出来的透光度就等于云层的自阴影。
就像上图所示,我们是用了四个样本,就分别以一个2次方的距离来分布。主要是优化了比较接近的一个遮挡效果。作为一个LOD策略在阴影采样云层的时候,我们也会忽略了细节噪声。刚才提到的相位函数,我们是用了一个经典的方法,就是把两个相反方向的HG函数混合作为最终的相函数这里列出了我们的一些默认参数,其中的VoL就是太阳跟视线的方向的一个点积。
为了节省性能,环境光方面我们采用了一个比较简单的处理方法。天空的环境光是参考UE4中的方法,根据采样点的高度来计算相关的颜色,就是说云越高,天空环境光的亮度就会越亮。
上图就是天空环境光效果的对比。我们发现如果没有环境光,云大部分在阴影的时候都会很难区别它的形状。而在开启了环境光之后,云在阴影部分的造型也清晰很多了。我们也加了一个用来模拟地面反射光线的地面环境光,主要是用来模拟地面反弹到云层的光线。
这也是以同样的方法来计算,但就是把高度的参数反转,就是说越低的云层就可以接收到越多的地面环境光。地面环境光的颜色是通过是通过将地面视为一个纯Lambertian的表面计算出光源的反弹所得到的。举个例子SkyLight的底部颜色就是作为地面环境光很好的一个数据。
这里我们可以看到没有地面环境光的时候,云的底部是比较暗的,而打开地面环境光之后云层的底部会变得更亮,更接近我们在日常生活中看到的一个结果。接着是多次散射模拟,也是计算出云正确光照非常重要的一环。因为云主要是从水蒸气和小的冰块所组成的,当阳光进入到比较薄的云层的时候会经历过很多遍的散射才到达我们的眼睛里面。从这一张照片里面我们可以观察到一个很有趣的现象。
云深处会比外面更亮。这是因为深处的云会经历过更多遍的散射。我们参考了彼思动画和寒霜引擎中用来计算多次散射的一个近似值的方法。我尝试简单说一下它的思路。
假设每一次散射的时候,阳光和阴影都会有一定程度的衰减,而相位函数也会越来越接近一个均向性。在这个公式中,我们定义了ABC三个0到1之间的参数来控制这个衰减的关系。注意一下为了保持能量守恒呢,A是需要小于B的,因此在光线行进的时候,我们就利用这个方法对每一个采样点计算出三遍的多次散射的一个亮度,在寒霜和虚幻引擎的实现里面,基于性能的关系,一般只会算3遍的散射。
针对这一点我们做了一个小小的改进。通过固定的ABC三个参数,我们可以预先算出一张查找表用来算出任何次数的散射效果,这个查找表是在归一化的亮度所计算出来的。我们是通过采样点的光学深度和太阳与视线的点积来进行一个索引。
这是没有多重散射的结果,因为光线没法到达云层的深度,因为光线没办法到达云层的深处,云层看起来会有点不太真实。有了多重散射之后,云层的整体亮度可以变得更为准确,效果也更为逼真。
除了多次散射之外,我们刚才也提到云的边缘部分会因为比较少的散射而显得会比较暗。我们也参考了地平线在2017年提出的方案,又添加了暗边的效果。
思路就是通过采样更小细节的云密度LOD从而评估出采样点出现散射的概率。虽然这个不是物理正确的,但它给出了归一化的输出结果,同时在不同的光线行进的步数之下也有同样的结果。
这是没有加入暗边效果的一个截图,可以注意圆圈中的部分。
当我们加入了这个暗边效果之后,会看到整个云层会呈现出更多的细节。
在这里我稍微总结一下是如何把刚才的理论总汇在一起。
我们从这一段处理单个射线的shader代码开始吧。FinalScattering 是我们最终看到的颜色,初始为零。TransmittanceCam就是代表摄像机和采样点之间的透射率,初始为一。当我们穿过云层的时候,散射的级会变得越来越大,而透射率会慢慢变小。
在这个For循环里面每一个采样点在这个For循环中,我们会计算每个采样点的云层的密度和散射的能量,而散射的能量就是刚才提到的在For循环里面。在这个For循环中,我们会计算每个采样点的云的密度以及散射的能量。而散射的能量则是刚才提到的多次散射,还有再加上这个暗边效果的一个得出的一个结果,再乘以目前的透射率就可以得到相机实际上接收到的散射能量,再以云层的密度去更新透射率。
注意一下,散射量和透射率更新的顺序是不可以交换的,不然的话算出来的结果是会不对的。
从刚才体积云的建模以及着色的分享中我们可以知道,要算演出逼真的体积云效果是有一个非常巨大的计算量。所以接下来我会分享是如何把性能的开消优化到手机上可以接受的程度。根据我们的经验,光线行进要进行64个采样才可以得到一个比较好的效果。但是在手机上,暴力地跑这样一个光线行进大概需要10个毫秒左右吧。
在《荒野大镖客》中,提出一个屏幕空间分帧的方法,虽然可以有效地改善性能,但是当相机快速旋转,或者是帧率不稳的时候,天空很容易出现像这个图中里面一格一格的情况。而相机快速旋转其实是在第一人称游戏里经常出现的情况,因此我们最终想到了使用半八面体投影来投影整个天空并且将光线行进的结果缓存在一张512乘512的2D纹理里面,再通过分帧更新。
这样做的优点是什么呢?首先缓存是独立于查看的位置方向以及相机的FOV。我们可以把缓存应用在任何的动态反射中重复使用, 这样不但解决了相机快速旋转的问题,在渲染天空在水中的倒影的时候,我们也不需要重新再做一遍的光线行进。
另一个好处是,云在半八面体空间中的运动是相对比较慢的,可以好好地与重投影技术结合起来。我们用了以下几个技术来进行那个更新的优化。第一个是棋盘渲染技术,这个方法帮我们节省了50%的光线行进的计算量。
首先我们有一个名为R的全尺寸的一个rt,它就包含了一个resolve之后的结果。我们有一张名为R的全尺寸的Render Target,它包含了最终的缓存结果。针对手机硬件的特性,我们就把R当中的像素分为两张只有一半大小的Render Target称为E和O。它们存储的R每一行偶数或者是奇数像素,因此我们最终会有三张Render Target,而光线行进只会在E或者是O中里面计算。
每一帧我们只会更新其中一张,然后就把算好的结果resolve到R里面用来绘制天空。把像素拆分到两张Render target的好处就是我们可以100%保证GPU不会有多余的线程在等待,或者是写入带宽的占用。
我们是用这一段代码把Seat里面的SvPosition转成光线行进所对应的方向。至于我们是怎么把结果resolve到R里面,我们只进行了一个简单的拷贝操作,并且把无关的像素Discard来节省了一半的贴图读取的带宽。
我们是利用这段代码来判断resolve当前的像素是否需要被discard。原理就是把当前的SvPosition先转成checker的坐标,然后再利用奇数或者偶数的ID转回SvPosition。假如转换前后的SvPosition对不上的话,就代表这个像素需要给discard掉。
之后我们会将Render Target再切分成4到16片来进行一个更大力度的分帧更新,这个想法也很简单。我们通过裁减矩形来限制算目标的哪个部分需要给更新。正如右边截图中看到绿色的矩形。我展示一下这个具体上是如何工作。比如说我们希望把Render Target分成4帧去更新,所以呢从一开始,每帧仅计算一行的像素,到这里E的更新就完成了,所以呢我们就把它马上Resolve到R里面,因此就让它的内容可以显示到屏幕上。
然后我们继续在O上进行更新,一旦O的更新也完成了,我们也会把它resolve到R里面。然后我们就从E重新开始一个循环。
然而这个优化也有一定的代价,就是它会导致像定格动画一样的情况。
为了解决这个问题,当绘制天空的时候,我们可以插值用来采样缓存的一个方向。比如说云层是比如说云是分四帧从A点移动到B点。假设我们距离上一次resolve已经过了一帧,同时再看到的是天空的B点,由于云的移动量是已知的因此我们可以往后回溯出并且找到C点。再利用相机到C点的方向,我们缓存来采样就OK了,我们看看插值后的效果。
我们刚才提到在光线行进单个方向的时候至少需要用到64步才可以有一个比较好的效果。因此我们也借鉴了基于TAA的技术,来进一步优化整体的性能,在每一帧里面我们会对每条光线起的开始点套用了一个全局的偏移,并且将结果和历史帧的结果做一个吻合。
比如说在第一帧的时候我们会计算蓝色的采样点,然后在下一帧就计算橙色的,可以看到所有的采样点都有一个偏移,最后是红色的。这个偏移是基于Halton序列来生成。我们其实是随着时间的推移在射线上计算很多很多的样本采样,结果一般会在几个帧里面收敛到。在我们的游戏中,加上这个优化之后,每帧只需要算16步即可以达到很不错的效果了。
同样的世界上没有免费的午餐,这个优化会带来鬼影的问题。所以呢我们再一次使用了重投影来缓解这个情况。在光线行进的过程中,我们会根据云的折射率,来评估出每一条射线的中心点,然后我们会把中心点减去云的移动量得到P点,再利用相机望向到P点的向量,从缓存来采样历史帧的数值来跟目前新的数值进行混合。
这是应用了重投影之后的结果,可以看到鬼影的问题已经大大减少了。除了如何降低这个光线行进的计算量,我们还有一个优化。
在一般的情况下我们会用半精度的浮点数来保存散射的结果。假设说一张512乘512的Render Target便会占用大概是2M的内存及写入的带宽,在主流的手机上这个是可以接受的。但是半精度的Render Target,在一些比较老的设备上不太友好,所以呢我们也必须要考虑使用8位RGBA的格式,然后我们也会面临以下的挑战。首先我们所有的单位是基于物理的,所有输入和输出都是处于一个高动态范围里面。
其次是在太阳的方向,有一个非常非常强的一个相位的峰值,这样让这个情况变得更糟糕。同时我们也需要考虑刚才提到时间超采样的一个数值的稳定性。所以呢我们使用了这个归一化的技巧来得到这个数值压缩的结果。
我们先看看这个表达式,首先我们将散射除以一个相位项,这样的话就可以降低整体的峰值。要注意的是,我们不是直接除每一个方向的原始的相函数,而是跟均向性的版本做一个吻合来避免过度压缩在阴影部分的一些像素。
之后我们会将散射除以一个预曝光的值,大概的的思路就是把阳光的亮度环境光等等的相关的数字相加在一起,保证散射的结果不会超出归一化的范围。最后我们做了一个伽马2.2的编码来提高数值的精度。这个就是我们归一化之后的所保存的结果。
这里是在Iphone11上一部分画质测出来的性能数据。作为与端游方案的对比,我们还使用了同一个场景,在GTX 1070显卡上进行了测试。在没有任何优化的情况下,光线行进在移动设备上,需要大概10个毫秒来更新。在最高画质我们会将缓存分为8帧更新,在手机上就只需要0.6个毫秒,如果在桌面上6帧只需要0.09的毫秒。在中等画质,我们会把分帧的数量增加一倍,在比较老的设备上,我们会使用最低的画质,我们会打开刚刚提到的HDR压缩并且将缓存减少到只有256乘256的分辨率。
最后我再分享两个跟体积云有关的动态天气效果。第一个是云在地面上的投影,因为我们将整个体积云的透光度保存在缓存里面,所以呢我们可以通过一个简单的计算就可以把结果投影到地上,做出云里的效果。首先我们是从着色的点朝太阳的方向发出一条射线,之后再计算出射线与云层底部的交点,最后地球中心与交点的方向去采样得出透光度,这个透光度就可以当成是云的投影来使用。
我们看看在引擎中的效果。云的投影可以跟随着太阳还有天气的变化所影响,令场景更为逼真的光效效果。为了有更好的性能,我们会建议把这个相交点的计算以及采样的方向放到顶点着色器去预先算好。
下一个效果是闪电为了制作出更为逼真的闪光效果。
我们参考了Youtube上一个打雷慢动作的视频。在现实生活中闪电有三个阶段,Lightning Leader这是一条像蜘蛛网一样的路径,从云端延伸到地面上。实际上肉眼是几乎看不到这个现象的,因为它来得太快。但有了它,它的效果是截然不同的,所以我们确定要保留这一个(效果)。
当Lightning Leader到达地面的时候,就会产生一个闪电的通道,电流就会从里面通过,把空气加热到一个非常非常高的温度就出现闪电和雷声,这个阶段叫Return Stroke。在Return Stroke之后,又有若干次Re-Strike。Re-Strike会在同样的通道里面去发生的,但一般都会比Return Stroke比较少,平均发生3到4遍,然后会产生一个,闪烁的一个效果。
我们是利用分形算法来产生Lightning Leader的网格。首先从一条直线开始,我们把直线的中心垂直的方向偏移一点点,最后把直线分为两截,在新生成的两截重复再做同样的中心偏移,直到每一节的线段够短为止。
在线段细分的过程里面,我们需要随机生成一些分支,这样才可以得到刚才说像蜘蛛网一样的效果。假设我们是刚刚生成橙色的线段,我们可以把橙色的中心与蓝色的中心点相连在一起产生一个红色的分支,我们再把分支随机缩短和旋转,之后在新的分支里面再进行刚才提到的细分的一个操作。
然后我们会把线段转回一个四边形的网格,R的通道就用来标记目前的顶点是否属于主干,G通道就保存了与闪电起点,G通道就保存了与起点归一化的距离就原来模拟闪电从云层到达地面的一个生长的动画。这是我们最终渲染出来的一个结果。
我们减慢Lightning Leader的速度就让我们可以更清晰地在这里展示,这是最终在游戏中能看到的效果。
可以看到场景和体积云会被闪电所照亮。场景方面,我们只是简单地增强了主光源的亮度,并且用相机和闪电之间的距离基于平方来进行一个衰减,当中的影子可能是不正确的,但因为它发生得太快了,所以很难被注意到。
云的方面,我们在渲染天空盒的时候,会考虑到目前闪电的位置基于一个指数的衰减来调整上方的亮度。云的位置是利用视线和云层的相交点,因此结果也不一定是100%准确,但对于移动端来说已经是足够了。
这是我们参考过的一些文章。今天我给大家分享了《暗区突围》里的动态天气渲染技术以及一些相关的天气效果的实现方法。
我们并没有止步于此,我们正在推进风格化的天空支持以及正在研究如何以一些新的技术来优化体积云的性能和效果。整套方法都是从错误中学习和打磨出来所以我也非常感谢我组内每一位参与了天气系统开发的小伙伴,也感谢《暗区突围》项目组的耐心和支持。最后也要感谢TGDC大会的邀请,让我再一次有机会公开我的工作成果。
谢谢!
|
|