今天学习的是顽皮狗在GDC 2012上关于水体渲染的分享,照例对文中的一些关键内容做一个简单总结:
- 通过一个shader将反射、折射、浮沫、churn(水下的剧烈扰动)、基于深度的着色都覆盖进去了,且所有参数都是美术同学可控制的
- 基于flow map(两次采样并混合)实现了水流效果,其中spline用来标注流动方向,用2D贴图来标注各个点的流动速度、浮沫、相位等数据(以grid的顶点为单位)
- 水体mesh的没有用效果较差的perlin噪声,也没有用美术同学控制比较痛苦的FFT,也没有完全用随着octave数目上升性能消耗急剧上升的gerstner wave,而是用了4层gerstner wave,4层wave particle以及一层大波浪来实现
- mesh的数据结构用的是clipmap,不过做了一些针对水体的处理优化,包括T-Joints的消除、mesh的blending以及分patch的绘制处理逻辑(利用SPU的并行特性进行加速)
- 由于mesh是以patch组织的,因此剔除也可以基于patch来做,包括2 pass的剔除、portal culling等
- 为了避免水波侵入船体,这里会以船体的甲板来构建box,对超出box的顶点将之压低
- 水下的雾效是直接用一个面片(顶点随波浪顶点一起起伏)实现的
- 水体上某个位置的高度查询不是通过对mesh ray casting得到的,而是采用类似牛顿寻根的迭代算法得到
- 水体的渲染只用了2.7ms,消耗比较高的部分为tessellation+wave displacement,大概花了8ms

深海是动作冒险游戏,核心是战斗跟探索而非水上gameplay。
场景种类多样,包括丛林、神庙、洞穴、废墟等,里面会夹杂各类水体,如水坑、溪流、河流、池塘湖泊、大海等。
水体元素对游戏玩法有着较为重要的作用,在一代代的作品中也在持续提升。




水体的表现形式多样,从小尺寸的水坑到大尺寸的海洋


Gameplay也有跟水体有关的玩法,比如浸水之后的衣物潮湿

在水体上的移动速度也会导致不一样的表现

水体的效果需要跟游戏画面的风格相匹配。

这里参考过很多此前的工作,其中Scientific visualization的工作在后续的工作中有很好的体现。

水体的实现覆盖了多种效果:
- 水流:通过(基于顶点或像素)滚动的UV,或者基于向量场生成的法线贴图来实现
- 折射:基于水深来抖动和着色
- 浮沫:在梯度场上给一个阈值,超出阈值的即添加浮沫效果
- 剧烈的扰动:同样是基于深度的着色,这里会使用浮沫贴图来对水体的深度进行调制,之后进行混合,以实现volumetric效果的模拟。
所有的参数都需要支持美术同学的控制。


水体的渲染有如下的一些要点:
- 使用bump贴图来对法线进行扰动
- 基于扰动的法线来计算得到fresnel项,并基于这个数据完成折射与反射的混合
- 在折射、反射效果之上,再叠加浮沫,这里还可以将浮沫的效果跟折射数据做混合来实现泥浆效果
- 基于剧烈的扰动(churn)来实现volumetric效果
- 折射的着色跟当前着色点处的折射光线到水底的距离(深度)有关:
- 这个深度的着色函数可以是线性的,也可以是指数的
- 基于深度的着色可以受到剧烈扰动(churn)的影响,这里的做法是用浮沫贴图的某个通道数据(因为受海浪高度影响,这里其实也可以理解成基于高度)来对深度颜色(depth color,水体因为深度不同而呈现的颜色,通过查表得到)与扰动颜色(churn color,暂时不清楚这个颜色代表的是啥)进行混合(不是太明白这里的含义)
- 在浮沫移动(被海浪的高度所调制)的时候,浮沫的颜色就会发生变化,看起来就像是浮沫在滚动
- 反射数据也可以直接添加到折射数据之上,而不是跟折射数据进行混合
再次重申,所有的参数都可以交由美术同学控制。

接下来一层层的解析下水体着色中的各个关键因素。

去除了bump之后的效果长这样:

叠加bump的效果,可以看到海面高光扰动细节更为丰富真实

前面说过,折射跟反射是通过fresnel系数完成混合的(物理公式),先看下只有折射效果的样子

再基于水深来对水体的基色与折射做一个混合,这里没有叠加bump的扰动效果

叠加扰动后效果大概是这样的

再加上软影效果

接着再基于海浪的幅度来对叠加浮沫。
foam跟churn会在水面之上跟之下叠加一个贴图(水下的浮沫效果就是水流扰动效果?),这俩都会受flow map的影响。

加上高光效果

最终效果如下图所示

接下来看看法线贴图是怎么处理的,这是水体渲染着色的关键。

Flow shader主要是基于Flow Visualization using Moving Textures方法(文中介绍了如何基于贴图的UV调整来实现流动效果,下面也会简介实现细节)实现的,主要的思想是基于向量场来对mesh的UV、Displacement等属性进行扰动对流(advect)

这里给出了flow实现的一个例子,在这个例子中,会使用VS来计算三角面片之间blend的数值,需要注意,当我们想让水流往东边流动,那UV就应该朝着西边移动。
可以看到,这里的flow需要完成两张贴图的采样,每张贴图的采样UV都会随着时间线性增长,同时,两张贴图的UV相差0.5个相位,同时两者混合的权重相加等于1,某个贴图的权重最高(=1.0)的时候,正好是其UV处于0.5的时候。

这里可以看到,流动方向不需要细化到像素级别,只要到grid的顶点级别即可

除了基础的流动效果之外,还可以添加其他的效果,这里通过一个second fetch来实现额外的流体效果。

这里给出了优化flow效果的几种方法,听起来有点没头没脑

这里来看下Flow Shader的实现逻辑:
- 先是基于当前时间g_time计算出两张贴图采样时的相位差fTime
- 基于相位差,得到当前UV的偏移flowUV1/2
- 基于偏移的UV对flow map进行采样,得到tx1/2
- 按照前面的理解,将采样得到的扰动信息作为UV的扰动再做一次额外的采样(优化flow效果,可选)
- 基于前面的混合公式完成两个采样结果的混合
这里的shader是基础版本,如果想要提升效果,还可以尝试一些其他的手段,比如:
- 添加一个起始的偏移,不同贴图的起始偏移可以不一样
- 通过对采样uv再加上0.5个flowDir的偏移来消除flow的distortion(原理是?)
- 对fTime的计算再加上一个offsetPhase来分散相位切换处的水流(spread around,需要在PS中完成)

这里展示了神海系列中使用到的maya工具,这个工具会使用spline来标注流动的方向,颜色贴图来标注流动速度(灰度就行了吧,还需要3色吗?)。
此外,如果需要的话,还可以生成foam map以及phase map(displacement需要)

这里是最终效果

接下来看看基于flow实现的displacement(水体mesh的高低起伏)。这里说到,水体mesh的顶点是按照各向异性的圆形移动的,不同顶点的相位有所不同(听起来是gerstner wave那一套)。
如果为每个顶点指定一个相位,那么整片水域就会需要一个相位场,而通过基于flow map来调控这个相位场,使得跟随flow的方向或者垂直flow的方向,就能够实现不同的波浪效果。

下面展示在相同的flow作用下,通过调整flow对相位的调节算法来实现不同的波浪效果



flow除了用在水体之外,还可以用于一些其他的效果,如沙、云、雪等

前面的水体效果在深海1/2中是够用了,那3呢

3中提出了一个玩法设想:在狂风巨浪中有一艘大船,突然转向穿入波涛中。
此前的基于displacement mesh的方式就不太能支持了。


期望实现如下图所示的效果:

emmm...

先来明确下需求:
- 水体渲染区域大,且要能支持100+的高浪
- 能够跟船舶等发生交互(不考虑动画实现方案)
- 支持游泳

海洋的实现中,海浪是程序化生成的(即运行时基于程序控制实现),参数可以调整,且同样的参数输出的效果是固定的,不随时间而变化的,可控性强。

先来解读下程序化实现的含义。
这里需要找到一个合适的模型来兼顾性能与表现,首先可以肯定的是不能完全走物理模拟来实现,耗费太高。
Perlin噪声实现的方案效果有点差,看起来有点假。
FFT方案效果比较好,但是参数控制起来有点痛苦,美术同学调整会很麻烦。

再来看看参数化控制的含义,这里给了一个函数,参数有:
- uv
- 时间t
- 其他参数
最终输出一个xyz的顶点坐标。
因为期望海洋的任何点的数据都可以通过计算得到,这里就不能使用grid,而是希望能够做到跟任何参数化公式的兼容,因此这里就需要给出一个组合式的海浪系统。
这里需要注意的是,我们这里最终是需要生成一个vector displacement,通过这个方式,我们可以不用高度场就能实现一个效果很漂亮的海浪。

最后看看一致性:即希望参数一致的情况下,效果要能实现精准的回放,以满足cutscene的效果需要。

最终选择的波浪是通过Gerstner Wave实现,这种波浪有如下的特点:
- 实现简单,但是高频细节较少
- 如果叠加使用的数目过多(20+),就会导致性能消耗急剧升高

FFT虽然效果很真实,但是想要调制出正确的参数就会比较困难,对于美术同学来说,负担尤其重。此外,在grid分辨率较低的时候,还会出现tiling的瑕疵。

最终这里采用的是Siggraph 2007上提出的Wave Particle方法,但在实现上,跟原文做了一些区分:
- 不再使用点源,而是在一个圆形区域里,放置随机分布的粒子来模拟开放水域波纹扰动的混乱特性
- 在某个速度边界的约束下对未知跟速度做随机,可以产生一个tileable的向量displacement场

这里给出波浪的解析公式,xy(代表的是啥?难道是粒子在xy方向上的位移?)都只受时间驱动,其中x还受一个参数控制,从曲线分布来看,这个数值越大,产生的波浪就越尖锐。

释放600个粒子后的海域波纹效果长这样

Wave Particle方法有如下的几项优势:
- 美术同学调整会非常直观
- 不会出现tiling问题
- 性能好,适合SPU的并行计算
- 能够保持一致性的效果:每个粒子的新的位置是通过初试位置、速度与时间推导而来,不会跳变

采用FBM的思想,通过不同频率、振幅的displacement field来叠加可以得到更为真实的效果。

这里还采用了类似clipmap的思路来对不同octave数据进行叠加,还可以产生LOD效果。

实际上,这里并没有完全抛弃gerstner wave,而是在四层gerstner wave的基础上叠加了四层wave particle grid

还增加了一个flow grid的概念(听起来是以grid的顶点为数据存储的基本单位),在一个grid中完成flow、foam以及振幅因子数据的集成。
下图是只有flow curve作用下的示意图

这里则是在grid中的flow vector作用下的示意图

前面说过,还存储了wave的振幅乘法因子。

最终生成水体mesh的时候,会将foam的数据调制转化为顶点色。

看下大图效果

再来回顾下最初的目标。

这里还差了大尺寸的海浪的效果。

所以在之前的两类wave上,再增加一个大尺寸海浪。
这个大尺寸的海浪会由美术同学手动调制后叠加上去,这种海浪通常会用在一些crash wave scene(没太明白这个意思),同时,还会用于阻止玩家游离gameplay area。

大尺寸海浪是通过对一个矩形区域的覆盖处理来实现。

这个曲线的u方向会被用来模拟一条样条曲线

之后沿着v方向对样条曲线进行复制,从而起到将平面拱起的效果。

但是这样得到的波形在两端就会出现裂缝,为了实现平滑的衔接,需要将拱起的波形朝着两遍做抹平处理。
最终的波形就是通过下图中的公式来给出。

之后,也可以根据需要对波形进行缩放、平移来实现动画效果。

下图展示了通过这种方法实现的大尺寸波浪效果。


下图给出了整个波浪系统的计算公式,可以看到,整体的波浪由三部分累加得到:
- 多个不同频率的gerstner wave
- 多个不同频率的wave particle grid
- 美术调制的大尺寸波浪
大尺寸波浪使用的是一个标准的不支持旋转的样条曲线,当然也可以考虑其他的如贝塞尔等曲线,但是代码量会更重。
grid(u, v)函数返回的是一个标量,按照前面的描述,在不同的场合有不同的含义,这里代表的是对波形进行累乘的因子。

接下来看看水体的mesh是如何表达的,总的来说,可以通过多种方法来表达:
- 屏幕空间的projected grid方法,可能会导致锯齿问题(?)
- 也可以使用quasi的projected grid方法,但是在大尺寸的displacement的实现上,就可能满足不了需要(所有projected grid方法都有这种问题吧)。

这里采用的是基于Siggraph 04年的Clipmap方法实现的非常规Clipmap方案,所谓的非常规就是针对水体做了改造。

主要做了如下的几点改动:
- 为了避免跨level之间衔接的T-joints(裂缝)问题,做了不同的split(划分)方法
- 在多个level之间做了动态混合(目的是?)
- 采用了patch的方式,以进一步利用SPU

这里给出clipmap的示意图。在相机移动的时候,各级的clipmap也会跟随移动,这里会保证任意点在相机移动前后,采样的数据都是相同的,从而避免抖动以及锯齿问题。

这里将第0级(完整的quad)之外的其他level叫做ring,精度越低,ring的级别越高。

下面截了一堆图用来展示相机移动后ring的包裹范围,应该还介绍了一些其他的内容,但是在备注中没有说到,后面有需要可以从原始视频中找信息。








用单个ring来解释

绿色的是原始的patch(一个quad就是一个patch),红色的则是额外添加的patch

?没看懂这里想要表达的是什么。

附加的patch可以起到承上启下的作用,以规避两级ring之间的顶点密度不一致导致的裂缝。

裂缝的修复是通过如下的顶点layout完成的。

这张图画的不是很明显,在修复裂缝的那一圈顶点或quad之内的顶点会调整其位置,使得网格密度有一个从粗到细的变化,以避免数据跳变太过严重,产生视觉瑕疵。

这里用颜色标注出修复区跟混合区。

接着来看下,怎么做剔除以提高绘制效率的,整个剔除分为两个pass完成:
- 先基于patch的四个角上的顶点添加一点余量做成box,之后做一次快速的相交测试
- 之后未被剔除的box会经历displacement的处理,就能得到一个更为准确的box,再基于这个数据做一轮新的剔除


这里给出了clipmap渲染的wireframe模式。

在舞厅中依然是可以看见水面的。


为了优化室内视角下的剔除力度,这里还用了portal剔除的思想。
对于那些浪的尺寸较大的情况,如果不做处理会导致浪穿透甲板进入到室内,这里的做法是用一个box来对wave进行求交测试,当相交的时候,就需要对不可见的部分的顶点做压低处理,将之压平到跟box的底部一致。

这里还有一个画面需要兼顾,主角会进入到船舱进行破坏。

船舱中的水体也是采用跟海洋一样的方式进行绘制的,只是shader跟参数会有所不同。

这里看下线框模式。

当船翻的时候,还会出现新的情况。


再看回舞厅。


由于一次性只能绘制一个海洋,因此在游轮上,需要经常的切换不同的参数来绘制不同的水体效果,比如在两帧之间切换shader跟参数。
由于游轮倾斜了90度,所以天光部分就变得困难。船外的水体还需要考虑水上跟水下的部分,此外,还会有一个洪水涌入的环节。
为了用一个平面来完成剔除,还要将船的移动约束在一个平面上。

从窗户外可以看到水上跟水下的部分。

洪水涌入部分的实现可以参考GDC 2012中Creating the Flood Effects in U3

这里来看下天光部分,这里是俯视角下的示意图。

同样以patch为粒度进行剔除,不可见部分用红色表示。

之后剔除掉不需要被天光影响的区域,用黄色表示。

还依然可见的部分用紫色表示。

对于天光来说,在渲染上,就是通过一个平面剔除来规避天光的影响(绿色部分),而水面mesh顶点则是通过压低到对齐船体box来实现。

水下的雾效是通过一个‘窗帘’面片(边缘顶点对齐波浪起伏)来模拟的,这个面片会跟随水体而移动。


通过线框模式可以看得更清楚。


这里给出了窗帘的mesh效果。


对水上物体的浮力的计算,这里会对相交区域的多个点进行采样,并调整对应位置的水体顶点高度。


游轮这边的处理要复杂一点:
- 会沿着船身进行采样
- 但是不会对所有的波浪都采样,只选取那些低频的波浪
- 对于船头,会用一个弹簧来模拟

在实现过程中,有比较多的需要对单点的水面高度进行查询的需要,比如角色游泳等,而要想获得精确的数据却并不那么容易。

这个问题可以用数学符号抽象一下:对于一个给定的点p<u,v>,需要找到水体对应的世界坐标r(u, y, v)
这里采用的是Ryan Broner的搜索方法,其原理跟Secant方法类似,不过是运算在3D而非2D空间:直接对wave field数据进行搜索,而非构建一个mesh之后走ray casting

根据p的uv坐标,我们可以运算出其在波浪方程作用后的世界坐标,如下图所示的1点。

将1点的坐标做一下投影,得到新的uv坐标与1点的高度,如下图中橙色的1。

从p点到橙色的1点,会有一个数据差。

将这个数据差反向施加到p点上

得到一个新的点2。


基于2点,我们可以通过波浪方程得到其水体上的位置,如红色的2点。

对红色的点再次做投影

得到新的跟p点的差值

同时得到原始2点跟投影2点的差值

基于这两个差值的方向,我们可以直接选取2点的投影点作为3点的位置。

重复上述过程

当3点的水体位置出现在问号点的右侧(看投影点在p点右侧),就重复上述过程。




这里不再是直接将p点反向减去差值,而是将3点反向减去差值,这也可以理解,毕竟红色的3点是3点计算得到的。


经过多轮迭代,最终我们得到了命中问号点的uv坐标与问号点的高度。




下面介绍下Mesh是如何计算的:
- 对于每个ring,会跑一个SPU任务来对patch进行处理,这里每个任务会以3为周期处理各个patch(不知道这么做的好处在哪里,实现负载均衡?)
- 通过double buffer来实现数据的输入跟输出

由于每个任务产生的mesh patch都能很好的缝合周边,因此这里也不需要一个额外的pass来对patch做缝合了。
最终输出的mesh包括了多个mesh部分。

整个计算的流程如下图所示:
- 在PPU中完成相机的计算、job的发起,并设置barrier等待所有内容的计算完成
- 在SPU中则负责wave particle的创建、更新(之后就已经有了所需要的displacement grid数据),水面点位的查询,clipmap的计算等
- PPU等待所有的SPU任务完成后就会进入渲染阶段

这里给出最终的性能数据:
- wave particle的计算花费了0.9ms
- 点位查询花费0.1ms
- 8ms用于tessellation与波浪的displacement,这里是在5个SPU下计算的,差不多有7个ring的21个任务
- 渲染只花费了2.7ms(大约50+个patch)
- double buffer内存大概是1MB



