瀑布联句:
千言万壑不辞劳,远看方知出处高。 —— 黄檗
溪涧岂能留得住,终归大海做波涛。 —— 李忱(唐宣宗)
今天分享的是《地平线2》在SIGGRAPH 2022上分享的关于水体的渲染方案,原文链接在文末给出,水体的效果,可以参考这个视频。
照例对这篇文章做个总结:
- 基于离线烘焙的方式来得到各种特殊效果的形变数据,如海浪、湍流、旋涡、瀑布冲刷效果等,并用RGB贴图的方式存储各个顶点的形变数值,在运行时通过采样贴图来实现模拟,为了模拟动画效果,这里的贴图就需要存储多份
- 实际上,除了位置的偏移信息之外,这里还需要存储一些其他的数据,如用于法线计算的binormal、tangent信息,避免效果形变的UV偏移信息以及其他用于效果控制的信息,都是通过烘焙的方式存储在贴图或者顶点色中
- 通过一组曲线来存储海浪的基本信息,如强度、端点、形状等,在运行时会基于这样的曲线来计算每道海浪的位置(时间+横轴轴距),同时,基于这些参数还可以获取到每个点的动画参数以实现符合预期的动画效果。不过这里倒是没有说到这些曲线是怎么存储的,推测应该是需要一张贴图





地平线方案相对于其他项目的方案,其主要的特点在于支持大尺寸的海浪,如下图右下角小图所示:

美术风格上想要实现下图所示的大尺寸海浪,性能上应该是扛不住的,这里最终考虑是在离线烘焙,之后在运行时播放,这种离线烘焙运行时播放的思路,在其他项目中也有应用:
- Killzone3的Streamed mesh data方案
- COD WWII的Houdini模拟方案
相比而言,地平线这里的需求要复杂一点,因为海浪最终还要契合到对应的海岸线,构成近岸的海浪效果,此外,除了大尺寸海浪,这边还需要考虑一下其他的一些效果,如河流、湖泊、瀑布等的漩涡、垂直流动等特性。
综合起来,基于Wave Particle方案的启发,这里采用了一种混合方案:
- 离线基于Houdini进行模拟
- 运行时将模拟数据取出来进行播放,同时将数据与运行时的一些特性(玩家交互)等进行混合
这里的一个问题是,如何用小尺寸数据保存大尺寸的海浪,并且在运行时能够高效的播放出来?

从实际的海浪效果来看,我们可以有如下结论: 海浪在移动的过程中,其形状基本不变,看起来就像是一个物体在平移。
因此,基于这个考虑,我们可以烘焙一个Wave的Animation,并且在运行时控制其播放,同时调整其Transform:

先来看看离线烘焙部分。
这里先用Houdini离线烘焙一个breaking wave,如下图所示,这里我们可以对这个wave动画的每一帧进行关键信息的抽取,对于每一帧,我们可以将海浪的形变数据表示为一个平面的形变,形变数值用每个点的offset表示,这是一个3D的vector,可以转化为一个RGB颜色,这样的话,每一帧(假设横截面是相同的,那我们可以只存储横截面的形变信息)就变成了一条1D的彩色线条,多帧数据就组成了一张2D贴图。
虽然最开始期待用Houdini来完成烘焙,但后面发现存在比较多的额外工作要做,最终还是换成人工来设计实现了。


接下来看看这个形变数据在运行时怎么用起来的。
这里展示了两道波浪在朝着海岸移动,左边的要稍微弱一点,可以看成是起步阶段,右边则是随着移动不断积蓄能量后的效果。
不考虑这个海浪,海平面本身是平整的,因此要想将两者结合起来,只需要在合适的位置,将海浪的offset数据施加到平整的海面顶点上即可。最终绘制几个海浪,取决于deformation texture(控制海浪范围?)。
以右边这道海浪为例,从左往右,海浪的强度依次递进,这里是按照如下的思路来设计的:越靠近海岸线,对应的强度越大。当达到最大值时,海浪就要开始进入破裂状态(如数值0.5),所以对于一道海浪来说,垂直于海浪前进方向,构成一条横线(对齐海浪的最高点),线上每个点都有一个动画参数(控制当前应该出于生命周期里的哪个位置),之后查找前面的表来控制顶点的形变即可。

海浪的顶点数据是通过CS生成的,CS的输入是海浪的quad列表,如下图中的绿色quad所示,每个quad会存储四个顶点数据以及左右两侧的动画参数。
在CS中会查找每个quad覆盖的顶点,对每个顶点应用quad的动画参数实现不同的形变效果
由于形变会导致表面复杂度增加,因此想要得到好的效果,需要应用tessellation方案。

为了得到一个较好的表现,这里还需要一些额外的数据:
- 基于位置偏移贴图,通过偏微分得到的法线数据(binormal跟tangent)
- 位置挤压之后,为了实现着色效果的一致,需要对UV进行调整,这里同样烘焙了一个贴图
- 其他的如foam数据、相对高度数据、形变强度数据等存储在顶点色中
因为最终所有的breaking wave都可以用同一份数据来表示,所以总体消耗其实是可控的。

前面介绍了breaking wave是如何通过形变来实现,接下来看看怎么控制wave的形状,控制形状的数据是在离线的时候烘焙好的,工具集成在引擎里,输出的动画数据会跟地图数据存储在一起,并跟随玩家视野而streaming。
这里也补充说明一点,wave的形状是美术同学手工控制的,而不是自动模拟抽取出来的,有两点原因:
- 希望有较强的美术效果的控制力
- 要支持gameplay能根据需要调整高度、强度、方向的需要
接下来介绍一下海浪的整体模拟。
如下图所示,左侧是一张现实海域的卫星图,右侧是编辑器截取的游戏画面图。
对左侧的图进行分析,我们得知:
- 海浪从左下角出发,往海滩方向前进
- 进入海湾后开始散开
- 进入近岸区域后开始破碎
- 碰到海岛,可能会改变方向,或者说产生新的方向的海浪

波浪的第一个属性类型是形状曲线,用于定义波浪的整体形状走势,如下图红色曲线所示。
卫星图中的形状曲线可以看出跟海岸线是比较匹配的,游戏中的形状曲线则是接近平行的

黄色的是引导线,用于定义波浪的首尾,同时基于这个数据,我们还能知道波浪之间的连接关系。

蓝色的是最终的动画数据,前面说了,这部分是离线烘焙好,在运行时根据需要streaming进来。
这些数据表示的是波浪的前进方向,跟形状曲线是垂直的,蓝色线条上的每个点对应于不同的时间刻度,对所有的蓝色线条基于同样的时间进行采样,就能得到wavefront。

将动画数据用彩色表示,不同的颜色表示的是波浪的烈度,然后,我们可以用带箭头的线(跟形状曲线吻合)来表示什么时候海浪开始破裂(如亮绿色的线条),所以在海浪曲线往前移动的时候,我们会同时采样动画数据,来控制波浪的高度,同时判断什么时候开始破裂。

汇总一下:
- 红色:海浪曲线
- 黄色:引导线
- 方块组成的网格:海浪的效果,颜色控制烈度


要怎么基于上述曲线得到正确的海浪呢?
这里采用的是一个函数,这个函数的自变量有两个,分别是时间跟横向的轴距。
时间代表的是海浪从出生开始往前移动的距离,不同时间对应的是一条条不同的shape曲线(红色),而处于同一条shape曲线上的各个点,对应的时间参数是一样的。
轴距代表的是到某个端点的位置,或者说到某个引导线的距离,处于同一条引导线上的点,具有相同的轴距。
这两个参数就可以看成是构成一个2D平面的两条坐标轴,如上图所示,红色表示横轴,黄色表示纵轴,有了这两个轴,我们就能锁定某个点的位置。
如上图中的小图所示,海湾里的红色shape曲线会跟五条引导线相交,就对应于上图左侧的最上端的情况

假设我们想要找到某个点,如上图所示的蓝色原点,只需要对包裹这个点的四条曲线做插值即可,用的是Steven Coons在1967年提出的算法,如下图所示(这里还提到了一个点,即实际情况可能比这个复杂,因为基于四条线得到的可能是一个非凸多边形,从而不能很好的实现某个点的覆盖查找,这时候就需要创建dummy edge,即辅助边或者虚拟边的方式来裁切掉多余的非凸部分,重新转回矩形):

本来有了这套坐标之后,我们只需要通过插值得到点的位置,之后获取对应的动画数据,就能完成渲染,但是这个过程放在运行时消耗有点高,因此最终将这个计算过程烘焙成一张2D贴图,直接采样就能拿到动画参数跟位置(相对?)数据。

如上图所示,近岸起始会存在多条海浪,而上面提供的方法虽然能够通过同一套数据同时实现多道海浪,但是其效果却是完全一样的,看起来不真实,因此需要添加一些差异变化,比如控制每道海浪内部的断裂点,如上图第二道海浪所示,这个可以通过调整水波形变强度数据来实现。

如上图所示,可以通过一张贴图来控制断裂方式(其实也可以通过函数),使得每道波浪都使用不同的断裂方式(不知道会不会随着时间推移而变化)


除了海洋之外,还需要支持河流湖泊的效果,比如上图中石头遮挡后的湍流,旋涡等效果。
这些效果同样会基于houdini离线模拟,运行时播放的方式来实现,不同的是,这里就不再是基于2D贴图来控制,而是采用3D贴图来驱动。

思路还是一样,houdini模拟后,通过算法抽取水体各个点的位置偏移,转成RGB颜色存储。

考虑变化,就得到了一个数组。这里引入的一个新问题是,形变的模型拓扑结构比较多变,甚至说会出现mesh分成几部分的情况,不能直接按照之前的算法来。

这里采用了Wyvill在04年提出的方法来解决这个问题。
如上图所示,这里我们想要模拟的水面mesh是一个大块的mesh加两个溅起的水珠,这里的思路是将之转化为一个隐式曲面+势函数。
具体而言,就是先用一个半球面包裹这个mesh,之后通过收缩,使之不断贴近原始曲面的形状,最终得到右图所示的贴合mesh。
势函数的作用是使得最终得到的模拟mesh的表面跟原始mesh的表面完全重合(不考虑断裂部分)。

如果不用势函数,而是用最近邻点匹配的话,结果可能会很糟糕,因为凹陷面处的某些点可能是没有办法被贴合的。
而之所以选择势函数,是因为项目组希望拟合的曲面可以像电荷吸附表面一样严丝合缝,选择势函数就说得通了。

这里采用的是最常规的方案,并基于Columb定律来实现顶点的形变。
但是整个计算过程需要通过数值方法来实现,速度快不起来,还需要做几轮优化。
最终顶点的分布是比较均匀的,从而可以较好的保障帧与帧之间的一致性,避免跳变。


基于这个方法,项目组创造了众多的局部水体效果,之后借助PCG工具将这些效果程序化布局到场景中,再交由美术同学进行人工调优。上面两图展示了瀑布效果开启跟关闭的效果差异。
当然,整体来看,还有一些内容可以进一步提升的。

这里列举一些可以优化的问题:
- 为了性能考虑,对生成的形变数据做了处理,以较少的顶点来实现效果的模拟,所以很容易发现效果比较平
- foam数据是通过顶点色存储的,因此不方便做一些像素级别的效果优化,且容易看到pop现象
- 通过tessellation来提升顶点密度,但是部分情境下tessellation的等级也做了压缩以剩下性能给其他特性,这里应该有不少可以挖掘的空间
- 期望实现像素级别的顶点密度,不过考虑到硬件的设计,小面片不友好,后面可以考虑基于splat的方案

- surface foam是另一个想要优化的特性,如上图所示,包括跟海滩的交互效果,以及跟石头、breaking wave、角色等的交互效果。Guided Bubbles and Wet Foam for
Realistic Whitewater simulation这篇文章针对这个话题做了深入的探讨,给了一个非常不错的离线方案,所以如果基于烘焙来考虑,说不定也可以尝试。

网友评论