4月初,腾讯魔方工作室群自研的硬核FPS手游《暗区突围》又一次开放了测试,截至目前,该游戏在好游快爆已经拥有了超155万的预约量,每次测试都能登上其新游期待榜TOP 2;在TapTap也有超过72万的预约量和8.5分的评分。
对于魔方来说,《暗区突围》应该是他们研发过的、美术品质最高的写实题材游戏。此前魔方总裁张晗劲也曾告诉葡萄君,在射击这条赛道上,他们的资源投入很大,头也够铁。
那么这些投入究竟效果如何?在最近的GDC大会上,魔方工作室群引擎中心的引擎专家陈家铭和引擎程序员应若晨,分享了他们如何做出《暗区突围》中主机级别的实时演算天气系统。
葡萄君将其分享内容整理了出来:
各位好,我们是腾讯游戏魔方工作室群引擎中心的陈家铭和应若晨。今天,我们想分享关于魔方工作室即将推出的手机游戏《暗区突围》中的天气系统。
首先,我们会讲解动态大气散射和体积云的相关细节,以及将它们带到移动设备上的优化。之后我们将分享一些相关的天气效果,和天气系统的设计思路。
01 低开销的动态天空渲染方案
天空的大气散射是一个复杂的过程,涉及到很多物理计算,因此我会试着用简单的方式来解释。
我们的大气由不同大小的粒子或分子所组成,天空的颜色是由这些粒子对太阳光的各种散射决定。
为了渲染出大气散射效果,我们一般需要在视点以光线行进计算每个采样点所接受的散射,然后计算它们的积。然而,这方法的计算量在游戏和实时应用上很难实现。
UE的大气散射优化
先让我们来看看Unreal Engine中的 SkyAtmosphere 方案,它使用了一系列查找表(Look up Table,LUT)来减少计算量。具体而言,UE 在每一帧中使用计算着色器生成4个基本的LUT,分别为透光度LUT、多重散射LUT、天空视图LUT和大气透视的3D LUT。
它不但基于物理,对美术也比较友好,同时在中高端移动设备上也能有良好的性能。但是老的设备带宽非常有限,负担不起每帧更新这么多的LUT。所以我们对UE的 SkyAtmosphere 做了一些优化:
首先,我们舍弃了大气透视数据,使用高度雾来代替它。舍弃掉这个数据后,所有剩余的LUT都是2D的,因此我们现在可以使用像素着色器来更新它们——这非常重要,因为许多移动设备仍然不支持计算着色器。
因为《暗区突围》局内的时间变化比较慢,所以我们分帧计算每个LUT,每帧只计算很少量的像素,在低端设备上也能承受。
为了进一步优化,我们将天空视图LUT 以半八面体参数化,并丢弃地平线以下的内容。这不仅节省了50%的光线行进计算量,也省了查找天空视图LUT时调用昂贵的平方根指令。
以下是原始版本和优化版本在一天中3个不同时段的比较。可以看到太阳周围有一些偏差,但这在游戏中不太容易看到。
而且如下图所示,优化后的天空在渲染性能上有显著提升。从原始的1.35毫秒到优化后0.77毫秒,半八面体投影节省了将近40%的GPU时间。
天气亮度与大气散射
美术有时需要将天空变暗,以获得更戏剧性的效果,很有可能他们会直接调整阳光亮度,但这不仅在物理上不正确,而且对我们的优化也不友好,会导致画面闪屏——天空颜色就像在一帧帧卡顿地变化。
我们的建议是先以固定的太阳亮度算出 SkyViewLUT,然后新增一个单独的美术乘数来调整最终亮度。如此操作,变暗的卡顿感消失了,我们也因此得到了更合理的散射结果。
02 体积云的实现
在游戏中,体积云的实现,可以归纳为这样几个问题:
如何对云建模。我们需要一些方法,来定义云层各处的云的密度。
如何计算光在其中的传播。特别是对于云这样的白色散射介质,光线可以在其中多次反弹,比普通的表面物体更难计算。
这种Raymarching方法一般很昂贵,我们如何让它在手机上运行。
云建模
首先是如何对云建模:
我们使用Worley噪声制作3D噪声纹理,将它平铺在天空中,就可以定义基本的云密度。
我们会使用基础和细节2层噪声,最终的噪声是通过基础噪声减去细节噪声得到的。细节噪声会平铺更多次,这样我们不需要特别高的分辨率,就可以获得足够的细节。
但是仅仅这样,它们不足以创造覆盖整个天空的云景。
于是我们引入了一张2D的Weather Map。在我们的系统中,Weather Map是由Cloud Mask组成的。每个Cloud Mask都有一个材质来指定绘制内容。
例如在下图中,你可以看到我正在拖动一个Mask。这个Mask的材质输出一个白色的Sphere Mask——因为白色意味着更高的覆盖率,因此那里的云变得更密集。我们也可以输出黑色,则该位置的云将被擦除。
为了方便控制不同天气下的云层,我们有两个全局参数:全局云覆盖率和全局云类型。这两个值将作为材质参数传递给Cloud Mask,并更改绘制内容。
除了Weather Map外,我们还用到一个称为Cloud Profile的纹理。
在现实生活中,不同类型的云在不同的高度有不同的形状。因为Weather Map只描述了XY平面的信息,所以我们需要使用Cloud Profile描述每种云在不同高度的形状。
我们使用了UE4中内置的曲线工具,借此可以在编辑器中轻松地创建Cloud Profile贴图。上图是这个工具的样子,我们能看到每个参数都是一条曲线,其中x是归一化高度,Y是它的值。
云光照
云的光照有5个部分:单次散射、多重散射、全局光、大气透视、自发光。
因为性能限制,我们没有计算大气透视,游戏中使用的其实是一个简化的计算,因此不会在这里讨论。此外自发光也比较简单,这里不作讨论。
单次散射
对于单次散射,我们通过太阳光、阴影和相位函数相乘来计算能量。
在阴影采样时,我们也不考虑细节噪声,并且在每一步上使用更高LOD。对于相位函数,我们用一个经典的方法,通过混合两个HG函数作为最终相位函数。
天空环境光比较简单,只是根据采样点高度计算颜色。云越高,天空环境光越亮。这其实是UE4中的方法,我们发现它很好用。
下面是开关天空环境光的效果对比。我们发现,如果没有环境光,云体有很大部分阴影的时候,很难分辨形状;而开启环境光后,云在阴影部分添加了漂亮的蓝色色调,造型就清晰多了。
上:无天空环境光 下:开启天空环境光
我们还有一个地面环境光,用于来模拟地面反射的光线。这是以同样的方式计算的,但是把高度参数反转,这样越低的云层受光越强。
地面环境光的颜色是通过将地面视为纯色的Lambert表面,计算主光源的反射得到的。
这里我们可以看到没有地面环境光时,云的底部比较暗。而打开地面环境后云的底部变亮,更接近我们在日常生活中的感觉。
上:无地面环境光 下:开启地面环境光
多重散射
多重散射指的是光子在云中弹射了不止一次。模拟多重散射对于正确的外观非常重要——云看起来这么白,就是因为当光束击中云粒子时,大部分会被弹开并继续进入云内,而不是被吸收。
特别是在云层深处,多重散射对外观的贡献会变得更加明显。这让云有一些反直觉的效果,比如这张照片中,云的内部比外部更亮,就是因为其内部的多重散射更加明显。
为了模拟这种效果,首先,我们参考了在寒霜引擎中的方法来计算一个大致的多重散射的强度。这个方法可以以较少的开销完成一个近似的多重散射的计算。
这是没有多重散射的效果,因为光线无法到达云层深处,云看起来更像烟。
这是在有多重散射的情况下,云层的整体亮度更准确,更像现实生活中的云。
接下来我们参考地平线的方案,添加了暗边的效果。注意下图中圆圈的部分,在没有添加暗边效果之前,我们很难辨别这些部分的形状;
但是加上了效果后,这部分就有了更多的细节。
体积云性能优化
基于Raymarching的体积云,在它的每一步都涉及大量的计算。我们一般需要至少每条光线64步,每一步都涉及许多纹理读取和ALU指令。在手机上,这样的计算量难以取得较好性能,以满足我们渲染天空的预算要求。
我们的方案是,使用半八面体映射,把Raymarching的结果缓存在一个512 x 512的2D纹理中。由于天气以及太阳方向在我们的游戏中变化较慢,因此我们可以将缓存分多帧更新;
另外,我们需要两张RT,一张用于渲染天空,一张用于分帧的Raymarching,并且在Raymarching完全结束时交换两张RT。
这么做的优点是什么?
我们可以把缓存在任何动态反射效果中重复使用。例如《暗区突围》使用了平面反射,天空在水中的倒影不需要再做额外的Raymarching。
云的运动在八面体空间中相对较慢,可以与重投影技术很好地结合。
同时,为了有效地限制计算量,以避免移动端的GPU过热。我们采用了以下策略:
1.棋盘渲染技术。这个技术有助于节省每帧需要计算的 50% 的光线。
下面的图片展示了棋盘格式更新的效果。
2.切片。我们通过将RT分成4-16个切片,我们每帧仅更新一个切片,来实现分帧更新。
然而,切片也会导致云在移动时看起来较为卡顿。为了解决这个问题,我们可以插值缓存的采样方向。
例如,云分4帧从A移动到B点,假设我们在当前帧中查看B点,我们现在比上次更新周期过了1帧。由于云的移动量是已知的,因此我们可以向后追溯并找到点C,从视点到C的方向将是重新投影的方向。
下面的图片展示了插值后的效果。
3.时序升采样。如前所述,不加优化的话,我们在Raymarching时可能需要64步才能有较好的效果。所以我们想稍微优化一下,节省GPU 预算给其他效果。因此我们采用类似于 TAA 的技术,在每一帧中,我们对每条光线的起始点应用一个全局偏移。并且将结果和之前帧的结果混合,来得到最终结果。
我们还使用了重投影,来减少云在快速移动时产生的“鬼影”问题。
无重投影,可以看到云层移动时重叠的“鬼影”
重投影开启,“鬼影”情况大大减少
4.压缩最小化内存存储和带宽。我们还使用了一些方法,来压缩体积云的存储大小。
通常我们使用半浮点型来存储射线行进结果,单张512 x 512的RT需要2MB,这对于主流设备来说是可以接受的,但是在旧设备上还是有优化空间,因此我们想尝试支持使用RGBA8的格式。
最终,我们将散射的结果除以相位函数,并且预计算一个曝光值来降低整体亮度,以及Gamma压缩等方法,成功将贴图压缩进了RGBA8的格式里。
5.可扩展性和性能。下面是在iPhone 11上测试的不同设置的一些性能。
可以看到在优化前,iPhone11需要大概10ms来完成一帧的更新。而在开启切片、时序升采样等优化后,我们可以将开销控制在1ms以内。
03 暗区突围中的动态天气效果
下面让我们来看看《暗区突围》项目中的一些动态天气效果。
云影
因为我们将体积云的结果保存在RT中,所以我们可以通过一些简单的计算,把结果投射在地面上,做出云影效果。
雨
它包含雨滴粒子和湿润效果,雨本身是用粒子渲染的。
湿润效果通过调整材料参数来达成,参考了Waylon的方法。遮挡贴图则使用了CSM Scrolling技术,来支持快速更新遮挡贴图。
然而这些细节仍然需要持续的打磨和优化。比如遮挡采样开销过高,没有太阳光和AO的情况下,场景显得太平等等,还需要更多的优化。
闪电
接下来是闪电,我想分享一些在实时渲染中制作真实闪电的关键点。
下面的动画来自YouTube上一个名为the slomo guys的频道,它清楚地显示了一次完整的打雷的过程。
现实生活中的闪电,有三个阶段:
Lightning Leader。这是一条像蜘蛛网一样的路径,从云端延伸到地面。实际上肉眼是几乎看不见这个现象的,因为它太快了。但这很酷,所以我们决定实现它。
Return Stroke。当Lightning Leader到达地面时,就产生了我们所说的“闪电通道”。巨大的电流从中穿过,把空气加热到非常高的温度,出现闪电和雷声。
Re-Strike。在Return Stroke之后,可能会有若干次Re-Strike。Re-Strike沿着同样的通道发生,通常比Return Stroke小,平均发生3到4次,产生闪烁的效果。
为了创造闪电,我们需要对它建模。这里我们使用分形算法,来创建闪电通道。
算法本身很简单,就是不断地分裂线段,每次分裂时偏移中间的节点。
在分裂一段节点时,随机地创建当前节点到末端的新线段,并且把它旋转一定角度,即可得到分支的效果。
然后,我们将结果转换为四边形组成的网格,同时用顶点颜色存储一些信息,来模拟从云层到地面的生长动画。
这是它最终动起来的效果。我减慢了Lightning Leader的速度,这样我们就能看得更清楚了。
这是它在游戏中的样子。
场景是通过直接增强主光源亮度来模拟闪电的光照的,强度则基于相机和闪电之间的距离计算。灯光方向不会变化,因此物体的影子并不正确。但是闪电发生得很快,所以也不是特别明显。
为了照亮云层,我们计算云和闪电的距离。云的位置是通过和云层求交来计算的,因此结果不是百分之百准确,但对于移动端来说已经足够了。
04 创作工具
接下来的部分,我想分享一些在我们的系统中关于用户体验和软件工程的东西。
天气创作
首先是我们如何在我们的系统中定义一个天气。
一般的想法是,我们可以有一个天气参数的结构体,里面包含了太阳强度、云量、雾密度等。这样不同的天气就是一组不同的值。
但是在我们的实现中,我们希望用户可以拥有最大的灵活度,所以我们避免了这样的硬编码。
在我们的系统中,用户可以使用UE的Level Sequence工具,直接将任何属性添加到系统中。系统将基于一天中的时间或太阳角度,来采样该Sequence,这样我们就可以让天气参数跟着时间变化。
并且,我们允许多个Sequence同时生效,多个Sequence之间有叠加顺序,较高Sequence中的值将覆盖较低Sequence中的值。同时每一层都有一个不透明度,通过控制不透明度值,可以控制Sequence的强度,就像Photoshop中的图层一样。
这样一来,用户可以将不同的天气元素参数,组织在不同的Sequence中。并且通过不同的不透明度组合出最终的天气。
举例来说,一个晴朗的天气的预设,它保存的不透明度可能是,基础层是1,云是0,雾是0.2等等。
我们再做一个叫做多云的预设,它的云、雾两个Sequence的不透明度较高;再做一个叫做雨天的预设,它的雨层会有一定不透明度。
Gameplay逻辑最终看到的就是这些预设,如果Gameplay要求改变天气,系统就只是在这些不透明度值之间过渡。
天体系统
接下来是天体系统,它帮助我们定位太阳、月亮或天空中任何东西。
这个系统允许你为场景定义时间和地理属性,包括一天中的时间、一年中的时间、时区、纬度和经度等。有了这些设定后,该系统可以根据天体属性,自动将它定位到正确的位置。
图像参考捕获
这个功能允许你把摄像机放在关卡的任何地方,只需轻轻一点,系统就会遍历时间和天气,渲染成图像并进行保存。同时它还支持立方体贴图和HDR格式。这样,美术可以更方便地迭代天气效果。
建议
最后是一些实现类似的天气系统插件的建议。
因为每个项目都有自己非常具体的需求。例如,并不是每个项目都需要体积云或者基于物理的大气。而且有些项目可能对材质有一些特殊的要求等等。
因此最好不要尝试做一个开箱即用的解决方案,否则类似的需求使得你必须为每个项目创建分支。在虚幻引擎中,材质和蓝图很难管理,因为它们是二进制的,维护多个分支会很麻烦。
我们最终使用了一个方案,尽可能的去解耦所有的功能,包括把所有的功能特性都分成Actor、Component或Material Function。用户可以在蓝图编辑器或材质编辑器中组装这些功能。
这样做,虽然让用户在早期带来了多一点的设置工作,但将来会少很多麻烦。
我们分享了在我们的天气系统中,如何渲染真实的天空,以及一些基本的天气效果,但这不是终点。我们也在致力于打造动漫风格的天气系统。并研究如何将把更极端的天气条件,如暴风雪,沙尘暴移植到移动端实现。
最后,我们要感谢魔方和魔方引擎中心为我们提供的支持与帮助。