图形引擎实战:FFT海洋系统开发手记(一)
最近由于项目需要,在URP底下实现了一个基本的FFT海洋系统,最终实现的效果如下:
视频链接:https://zhuanlan.zhihu.com/p/685966036
效果和功能基本是对标HDRP的海洋系统,具体实现上固然是参考了诸多市面上开源的类似系统,从编辑器易用性到渲染流程的合理性进行过多方面的评估。实现这种技术上已经比较成熟的系统难度基本都落在对于美术要求的转译以及基于项目需求进行调整与优化上,属于是一个难者不易易者不难的活。为了更好地概述本文的重点,这里我想引用一下一位大佬的感想:
来自:https://zhuanlan.zhihu.com/p/28042244,作者:Funny David
对于FFT海洋的各种相关数学知识,算法结构,已经有诸多学术界与工业界的前辈们进行了非常深入的研究与论证。本人浅薄的数理知识仅仅可以做到理解现有的一些资料,还远远做不到深入浅出地进行理论上的分析与拓展。同时,水系统是一个糅合了大量不同渲染技术的大杂烩,譬如单单反射这一项就能引申出大量的相关技术,在不同的性能、美术和流程要求下又会有不同的选择,实在是难以面面俱到地记录太多细节。所以这篇文章,将只能注重于我把技术落实到某个特定项目中的一些零碎的经验以及自己的理解,若能对各位在实际开发过程中稍有裨益,本人将无比欢欣。
言归正传,接下来我将把目前实现的功能拆分成几个部分分别简述其实现过程和原理,以及一些为了达到美术要求而使用的trick。
一、生成波浪信息
这个阶段的处理是一切水渲染的开端。无论是采样预计算的资产,还是实时计算波形需要的信息,这阶段的输出都已经大体上决定了水波浪的形态:譬如波峰尖锐程度,波谷的平滑度,波浪细节的丰富度等等。而这个部分业界所使用的技术纷繁复杂至极,不同的波形生成技术又会影响后续水体其他部分的实现方式,详细的总结可以看一下毛星云大佬的《真实感水体渲染技术总结》。
其实在前期需求调研阶段,我们也没有一开始就奔着实时计算波浪的这个方向去,还是本着能省则省的想法,测试了好几个使用预计算的置换图来计算波浪形态的插件,以及一些甚至没有顶点偏移只有法线扰动的水片shader。后面得到的反馈是,细节的丰富度还是不足,动态效果不够自然。不过最主要还是当时项目对于高配机的性能要求并不苛刻,所以就理所当然地,咱们开始看一些更高端也更费的技术了。
而所谓的FFT海洋,也不是写实波浪模拟的唯一选择。如上图英伟达在2011年就已经提到了五种不同的水体渲染方式,他们分别在水体波浪模拟和水体交互方面各有侧重。一些早年的3A游戏,如神秘海域,刺客信条等,则对于这些技术根据自家需求进行了不同的改进和组合,感兴趣的朋友可以直接搜索他们当年的技术分享看看,挺有意思的。
至于FFT这个类型的波浪模拟,总结下来,它的好处是:快速(相比其他类似效果的方法),且极其适合海浪以及开放水域的模拟;坏处是:没法在计算波浪信息时把水与物体的交互考虑进去的,以及它是无界的(性能不受水域大小影响,小池子和大海的波浪信息计算负荷是相等的)。首先,对于我们来说,这个“快速”就非常吸引人。在GPGPU已被广泛应用的时代,ComputeShader在各种意义上都应该纳入追求高质量画面项目的考虑。而一旦接受了在项目中使用GPGPU(起码对于高配机开启),一些前期的管线层级粗粒度优化策略就解禁了。使用妥当的前提下,可以极大提升画面中的场景物件以及各种美术效果的容纳量。
说了半天,还是没说这玩意是怎么算的。不过,现在起码明确了,我们需要一个足够复杂的数学公式,来计算水面上每一个顶点的偏移。而常用的基于波形叠加的波浪计算,常常会使用Gerstner波。这里还是直接放出一张ppt:
这个Gerstner波,可以理解为是一种波峰更尖锐,波谷更平缓的正弦波,更加接近现实的海浪形态。而通过叠加相位,周期以及波峰尖锐程度不同的Gerstner波,将会形成一个复杂的海面效果。
来自UE5文档
一般来说,咱们海浪模拟到了叠有限可调节波形这里就差不多了,但是呢,生命不息折腾不止啊。我们用有限个简单波形固然是能够搞出来还凑合的海,但是对比起地球online里的海洋,它好像有点重复啊。而且把海浪高度拉起来之后,这玩意就变尖刺陷阱了啊,那种波涛汹涌的感觉,好像有点难叠出来哦。就算理论上能叠出来,也不会有人闲得慌想要手动调整成千上万个波形参数。这时候大家就想,要是可以把现实中的大海涌动过程,以某种形式记录下来,然后在引擎里逐帧重放,岂不美哉?
也正因为有这种对于复杂信号(无限个波形叠加)的解析需求,一个跨时代的数学工具应运而生,它就是傅里叶变换。
难以避免地,咱们要加进来一点关于信号处理的知识简介。如果是已经了解这方面的朋友,可以直接跳到下一部分了。
在日常生活中,人们习惯以匀速变化的时间作为参考系去描述一个事物。放到数学上,就是以时间作为横轴(自变量)去绘制一个函数,这也被称为时域分析。而当遇到上述所说叠加非常多基本波形的情况时,比起每一个波形在时间轴上的表现(都是周期函数),我更关心他们的相位,频率,振幅等属性。这时,我们就应该像下图那样子,从这一大堆波形的侧面看过去,拍扁,这就得到了多个波形的频域表示。
来自:https://zhuanlan.zhihu.com/p/19763358,作者:Heinrich
而这个“从侧面看过去”,把时域信息转换为频域信息的操作,就是傅里叶变换(Fourier transform,FT)。反过来,从频域转换到时域,就是逆傅里叶变换(Inverse Fourier Transform,IFT)。具体一点来说,由于计算机只能处理离散的信号,我们实际上使用的是离散傅里叶变换(Discrete Fourier Transform,DFT)。
好,知道这一点之后,让我们回到具体需求上来:我们想要把现实中大海波浪涌动的信息记录下来,然后在实时渲染中逐帧重现。翻译一下,我们实际上是需要进行这么一个过程:分析现实中近乎无限的波形叠加数据(时域)→不随时间变化的数据(频域)→在实时渲染中逐帧变化的数据(时域)。
完成第一步的转换之后,我们将会得到一张记录频域信息的贴图,也称作海浪的频谱。而根据海洋所处地域和统计方式的不同,频谱的生成公式也会有不同,最终生成的波浪形态也会有区别(有点像BRDF中各个分项的选择)。以下分别是使用Phillips频谱以及PiersonMoskowitz频谱生成的结果,可以对比着看一下:
接下来,就以英伟达的Ocean Surface Simulation的介绍为例,咱们简单了解一下整个算法实现过程。如果想要看到更多的公式推导以及具体的代码计算,网上已经有很多详细的资料可供阅读,本文就不再赘述了。
他们的实现选择了Phillips频谱公式,即上图的Ph(k)。这部分描述的是,风速与风向对于特定大小的海面区域的影响。同时,还需要计算两个均值为0,标准差为1,相互独立的高斯随机数。这两部分组合就得到了上图的H0(k),即初始频谱的生成公式。在一般的实现中,这个初始频谱只会被风向与风速影响,所以如果不需要逐帧改变风的参数,初始频谱只需要计算一次就好。
第二步是根据初始频谱来计算海面高度的频谱H(k,t)。然后根据高度的频谱,在xz平面进行一个挤压操作(为了形成尖浪而进行的一些数学上的处理,类似Gerstner波在xz平面进行挤压使得波峰更尖的操作),得到xz方向的频谱。可以看到,这个部分开始已经有时间t作为变量了,所以接下来的计算是要每帧进行的。
第三步,就是根据xyz三向的频谱,进行IDFT操作,把频谱转换为时域上的顶点置换图。然后再根据置换信息,生成法线图和泡沫图。至此,海面信息转换就结束了,后面就拿这几张图渲染就好了。
这整个流程中,性能瓶颈在于IDFT操作。如果直接进行暴力计算,对于一整个海面的信息,时间复杂度将会是O(N2)。也就说我们想要计算一个512×512精度的海面,电脑每帧需要进行5124的循环计算,怕是直接冒烟了……所以,我们需要优化这个计算,也就是一直在说的FFT(Fast Fourier Transform,快速傅里叶变换)。
我们实际上用到的是IFFT,即快速逆傅里叶变换。但是大家都叫习惯了FFT水,也就没什么所谓了。这里FFT其实是代表一种算法思路,即巧妙地运用DFT计算中的对称性,把算法过程高度并行化,交给GPU计算,最终时间复杂度可以降为O(NlogN)。这部分的算法可以在网上找到大量各种语言的实现,用的时候直接把轮子搬过来就好。
对于法线图的生成,我这里直接用了最简单的差分法。如果想要生成更正确的法线图,可以看一下这位大佬的文章(https://zhuanlan.zhihu.com/p/64414956)。对于泡沫区域的计算,大家倒是基本上都使用雅可比行列式计算,有兴趣了解原理的话可以看看高数,实际的代码倒是挺简单的。
二、波浪参数调节
上图是我的实现中波浪部分的UI,参数设置参考了HDRP。总共渲染了三套细节程度不同的贴图:细节最少以及次少的两张被称作Swell(涌浪),细节最多的一张被称作Ripples(波纹),其中Ripples可以关闭。两层涌浪的UI面板上通过一个海域面积值(Repetition Size)、一个风速值(Distant Wind Speed)、以及一个无序程度值(Chaos)控制,而在渲染时这两层涌浪使用的海域面积值分别进行了缩放处理,实际上是不同的。另外,还加入了波浪细节采样之后随视距变远消隐的功能,主要是为了减弱远处的高频高光。其他的参数都是一些数值映射,采样偏移等等,就看看美术想要什么往里加就完了。
Mask功能是为了压低沿岸的海浪,防止浪比较大时会从沿岸地底冒出来。具体实现中,是在主相机移动一定距离之后,修改VP矩阵从上方给地形渲染一张正交深度图(如上图)。然后根据UI上输入的三层浪以及泡沫的渐隐参数对这张深度进行数值映射,生成遮罩,影响后续渲染的效果。
值得一提的是,虽然HDRP的实现也使用了Phillips频谱,但是整体的涌浪观感层次感更强(如上图红框中),而不是像原始实现那样看着比较鼓包。其原因是他们把IFFT之后得到的置换图进行了一个Shuffle操作,给yz分量乘上了一个可调的值之后,交换xy分量。也就是实际的顶点偏移为 (y, x, z) * (_Choppiness, 1, _Choppiness)。我事后诸葛亮地分析一下,这大概是开发过程中某个美术说想要层次更丰富,但是整体更平缓一点的海浪,所以他们就试着把值域更大的y分量当作了水平偏移量,确实结果还不错。但是这背后是否有什么理论支撑,我大致搜索了一下没有找到相关的文献,如果有大佬知道这个做法的具体原因还请分享一下。
三、近岸潮水效果(泡沫,变形器以及贴花)
手摆的泡沫和变形器部分就全是从HDRP进的货了,渲染流程也比较简单。只有相机周围一定距离内的泡沫变形器对象才会被推到GPU端渲染,范围内的对象列表刷新跟随上述所说的Mask功能走同一个刷新帧。上面的三张图,分别是泡沫区域,变形器置换图,以及变形器法线图。相当于相机从上往下正交渲染了这个区域内的组件。由于它们覆盖的区域比较大,所以精度是不高的。特别是当摆放的这些组件(都是方框)的本地坐标系朝向和世界坐标轴比较一致时,后续泡沫滚动或者波浪往前推的时候会有比较明显的走格子的瑕疵。主要就是因为这张图的单个纹素大小对应的实际世界空间中的区域太大了。摆斜线倒是可以产生一个天然的抗锯齿效果,所以最直接的方式是通过一些摆放上的注意或者让美术稍微修改一下海岸朝向来处理。
同时,我们还根据美术的使用习惯修改了一下UI层的东西。把原来用CustomRenderTexture实现的功能写进shader中,并且加了一些常用的遮罩功能。把原来的CustomRenderTexture资产-对应材质-脚本UI面板的交互,全都集成到脚本UI面板中,方便美术制作多个参数不同的资产。
目前泡沫形态分为两种:长条形的岸边泡沫,以及圆形的用来放在水中小物件旁边的泡沫,如上图。
变形器部分为Gerstner波变种,同时也会带着拖尾的泡沫,如上图。
冲到岸上的海浪部分,我们直接使用了URP自带的Decal贴到地形上。
本来也考虑过是否要专门为了海水而维护一个屏幕空间的DBuffer,那样就可以在水面上贴花,并且可以像HDRP那样子对拍上岸的浪头部分也进行比较高精度的顶点形变(如上图)。甚至可以再把海岸高度考虑进去,让拍上岸的部分根据地势高低产生不同的潮起潮落效果。但是权衡再三,感觉为了这个细节效果要付出的性能代价太大了,目前的优先度不高,所以没有实现。
不过归根到底,对于近岸潮水部分,目前的做法都还是基于手动摆放的。对于小场景手动摆放一下可以做的比较精致,但对于大世界类型的项目,并且有比较狭长海岸线的时候,手摆效率就有点低了。之前调研阶段也了解了一些程序化生成的做法:
最直接的做法是沿着海岸线自动排列多个海浪预制体,翻译一下就是沿线分布多个矩形。这种实现的话只要预计算出海岸线信息,用脚本写或者用Houdini实现排布都不难。主要的点在于美术效果的细节上,比如如何让多个海浪预制体分布看起来错落有致,潮起潮落的感觉比较自然,以及处理多个浪重合部分的问题。详细的可以看这位大佬尝试的结果:https://zhuanlan.zhihu.com/p/495906664;
另一种实现是根据海面到岸边的SDF图使用不同的阈值进行数值映射,就可以得到一圈不断从海面往岸上推进的遮罩。然后,对这个遮罩进行一些时间空间上的随机处理,就可以得到如下图类似的效果。
这个做法来自这里:https://outerra.blogspot.com/2011/02/ocean-rendering.html。原做法由于是一个整体使用了Skewed Trochoidal波形的海洋,属于是Gerstner波的一个变种(公式如下)。通过调节参数可以使得波形扭曲带有方向性(如下图),所以天生就很适合用来模拟近岸潮水,并且近岸部分的涌浪波形会和其余海水部分过渡得非常好。
至于海浪带有方向性的细节,比如流动泡沫,暗流等,可以根据这张SDF计算出一张梯度变化方向图(或者叫flowmap)。我在Houdini里头大概试了下,结果如下。由于flowmap的生成需要多重采样,所以还是走离线生成比较好。即只对含有海岸线的地块进行这些贴图的预生成,运行时采样。
篇幅所限,第一部分的分享就到这结束了。
本文权当抛砖引玉,如果上文有任何错误的地方,或者有更好的做法,还请不吝赐教,感谢您的阅读!
欢迎加入我们!
感兴趣的同学可以投递简历至:CYouEngine@cyou-inc.com
#我的成功项目解析##搜狐畅游##游戏引擎##技术美术#