今天介绍的时Shoestring的海洋渲染方案,该方案在Siggraph 2013中做了分享,照例做下总结:
- 本文将水体的渲染分为三块来介绍,分别是波形shape,网格mesh以及渲染shading
- 波形针对了高低端机采用了不同的策略,低端机用的是gerstner wave+抛物线调整的方式实现,高端机则用的是FFT
- 网格就直接使用了Projected Grid来实现,为了避免镜头移动与旋转过程中的闪烁问题,这里通过polar mesh做了优化,虽然还是存在些微瑕疵,但问题已经得到了很好的抑制。
- shading部分就比较常规了,经验介绍说浮沫以及跟随水深的颜色控制是非常有价值的
- 针对浮力这块没有做过多的介绍,因为是联机玩法,所以需要采用全局的数据,而不能是各个玩家的局部数据来实现物理的模拟计算。
这里用两个视频展示了最终的效果:
这里介绍了海洋研发的一些背景:
- 希望实现水跟地的无缝衔接
- 支持多个主机平台,其中包含了低端主机Wii(只支持固定管线,相关计算需要放到CPU上)
水体的实现主要可以分为三大部分:
- 形状Shape,介绍的是水体上顶点该以什么公式来驱动和模拟
- 网格Mesh,介绍的是水体数据的组织形式
- 着色Shading,介绍的是水体的效果表现
先来看下第一部分Shape
海洋表面的特点是宽广、变化复杂。
完全覆盖海洋每一处的细节,在实时上依然是不现实的,因此这里考虑将海洋分为两级来考虑。
Global覆盖的是全海域上的基础形状,而Local则是处于美术、策划玩法考虑,针对局部的短距的海域添加的一些额外的计算与效果。
同时,依然会通过法线贴图来为海域fake出细节表现。
傅里叶变换会得到如下图所示的结果,这里是针对单个patch的海洋表面的模拟。
目前已经有多个模拟海洋波浪的频域模型,可以考虑基于Ressendorf 2001的FFT方案来做尝试,通过对频率的限制,我们可以得到一个tileable & loopable的波形。
这个方法用在了Xbox跟PS平台:
- 使用了预计算的FFT贴图来得到多套displacement贴图
- Displacement贴图数据是8bit的定点数(实测精度已经足够了,运算都是用定点数完成的,最后转成浮点数),分辨率是64x64的,为了得到动画效果,一共需要64帧
- 总体内存消耗大约为1.5MB
上述逻辑是在CPU完成的,如果放到GPU上,可以用VS来采样,效率更高,且利用硬件的mipmap采样能力,还能自动过滤远处的高频信息避免抖动。
1.5MB的内存对于Wii来说还是太高了,这里会尝试使用其他的不用运行时bake的方法以降低内存消耗。
这里首先想到了Trochoid wave也就是Gerstner Wave,这种波浪在波峰跟波谷上能够很好的模拟海水的波浪的外观。
Gerstner Wave是通过参数化方程实现的,一个不方便的地方是不能够通过代数的方法计算出某个点的波浪高度,如果需要的话,就需要通过迭代的方式来逼近,带来的计算量会高一点。
这里决定通过分段的多项式来模拟,从而加速对某点数值的求取。
这是原始曲线
可以分解为3段抛物线(parabola)
第一个参数是cap width,用于描述峰值的sharpness。
这里给出了通过调整cap width来实现sharpness效果变更的示意图
这里演示了如何基于抛物线实现gerstner wave的模拟,从效果上来说,其实是希望峰值越sharp越好,不过需要注意不要引起插值问题。
在wii上,可以通过将这三个部分累加起来,同时在不同的地方采用不同的sharpness,从而规避掉海浪的重复。
下面来看看怎么添加一些局部细节。
一个简单的方法就是调整海浪的高度,比如将这个球状(或者圆形)物件放到对应的位置,并基于其上的数据来调整海浪的高度。
第二种方法就是采用wave particle。
wave particle不但可以影响海浪的波形高度,还可以用于控制浮沫效果。
wave particle生成的波形,还可以随着扩散生成新增的particle,同时降低其能量幅度,模拟自然界波形传播过程。
原文作者采用正弦波作为函数的kernel,这里采用的则是smoothstep。这种做法可以将部分项分解出来,供高度以及高度的微分项进行复用,从而减少计算的复杂度。
通过这种方式可以用来实现涟漪效果,当然小尺寸的涟漪还可以通过法线贴图来实现,但是大尺寸的话,就得有波形的配合,只用法线贴图就不太够了。
涟漪的效果最好是一个正向(拉高)的涟漪尾随一个反向(压低)的涟漪,之后循环反复。
需要一种能够快速查询wave particle效果的高度的方法。这里给出的解决方案是将wave particle的高度通过光栅化的算法写入到一个grid中,为了支持不同尺寸的wave particle,同时降低光栅化的性能消耗,可以考虑用多个覆盖不同尺寸的grid来承载不同尺寸的wave particle。
顶点数不足的时候,会导致插值的精度问题,这个我呢提在CPU的meshing上显得尤为明显。
对于不明显的区域,可以直接放弃分配顶点,注重性价比。
只在距离玩家足够近的情况下才显示mesh的高低起伏。shape的移除有上述几种逻辑:
- 移除小尺寸的波浪
- 移除傅里叶变换与wave particle的小尺寸波浪(缩小幅度)
- 在GPU上,FFT波形可以通过mipmap的方式来自动实现LOD效果。
通过降幅并不会解决采样不足带来的问题,但是可以使得问题不那么明显。
总结一下,经过上述设计之后,波形的效果跟性能都已经足够满足需要了。
下面看看如何基于shape得到mesh。
采样位置 & 三角化
对于mesh,这里希望是能够跟随形状而调整(从而实现该密的地方密)。
而且可以基于视角来调整mesh的形状(听起来就是projected grid的策略)。
在局部来说,则是希望mesh的密度聚集在一些频率高的位置(具体可以参考附录)。
第一种是clipmap
在预研阶段就被排除掉了,原因是推测可能在实现难度与执行效率上会存在问题,但是没有具体的证据。
第一个真正尝试的方法是Projected Grid。
有如上的特点:
- 实现简单,性能还挺不错的
- 会跟随视角的变化而自适应调整
- 在实现上遇到了插值问题:因为顶点会跟随镜头而移动,所以不能保证每次采样的顶点正好是同一个点,就会导致前后两帧同一个位置的高度会不一样。
如图所示,在顶点密度不足的时候,就会出现这个问题,因此远景的水波更容易穿帮。
在相机静止的时候,一切都是正常的
然而当相机移动或者旋转的时候,波形就开始跳变。
关于这个问题,如果我们能够保证在相机移动或者旋转的时候,屏幕空间的顶点投射后得到的顶点跟上一帧投射的顶点能够基本匹配(可错位匹配),那问题就能很好的解决。
这里引入了一种新的顶点组织与映射方式:Polar Mesh。
比如某个mesh上的顶点在旋转的时候,其轨迹是一段圆弧。
所以我们可以将mesh的形状做一下调整以适应其旋转的轨迹:让顶点以环形的方式围绕玩家,而非grid形式。
之后通过调整mesh的旋转角度,使得顶点正好匹配某个pattern,从而使得前后两帧的顶点位置基本重叠。
经过这个处理之后,在旋转相机的时候,顶点就不会再跳变了。
这里展示了效果
再来看下相机的移动,先看下向前移动。
其实这种移动可以看成是每段圆弧不断朝着中心缩短,而这个变化同样可以保障还在圆弧上的顶点的位置不变。这个移动在向前跟向后都能保证这个特性的成立。
那镜头在向左跟向右移动呢?看起来似乎是不能跟上一帧的顶点保持公用世界空间的顶点了。
先来看下,往前往后移动的时候,一个圆弧怎么被分割成两段(或者合并为一)。
这里说的是通过一条射线与二叉树进行求交来得到各个圆弧的半径。这里的问题是:
- 为什么是二叉树?
- 射线的方向与出射点怎么选取?
为啥是二叉树,这是因为mesh的精度是逐级递减的,用2D平面来看,就是四叉树,那放到单维度就是二叉树。
射线的方向要怎么控制,取决于多久之后要进入下一级LOD。
其实所谓的二叉树只是为了方便描述,在实际执行过程中,倒也不用完全采用这个方式,而是按照一定的pattern来控制单个方向轴上的顶点分布即可。
这里解释了按照二叉树的算法,这里会有一个节点会缺失。
之所以要构建这么个结构,其实就是为了模拟玩家在往前移动的时候,mesh的精度的不断自适应。
其实按照前面说的,我们可以将移动方向上的顶点按照一定的间距进行排列,最靠近相机的位置,间距最小,可以看成是LOD0,之后有LOD1等等。
我们可以控制一下各个LOD的切换范围,据此来控制每一级LOD的顶点数目,在相机往前移动的过程中,如果顶点数目还能维持住,那就不用变化,当顶点数目距离目标的少一的时候,就从下一级LOD借一个顶点分割为两个。这是个递归的过程。
这种做法相对于上面介绍的这种来说,好处是我们可以保证在相机移动的过程中顶点基本上是稳定的,但是就缺少了前面那种顶点在移动的过程中逐渐分裂,平缓从LOD1过渡到LOD0的效果,但是这种平滑过程说实话还是导致了顶点位置的变更,那么不就是依然会存在前面描述的那种精度抖动吗?
这里给了个视频演示
这是之前的效果,顶点闪烁很明显
采用polar mesh优化后,虽然不明显了,但还是依然存在,尤其是相机边缘区域。
在添加动画效果之前,闪烁明显
添加动画效果之后,闪烁还是有,不过没那么明显了。
再来介绍一下射线跟二叉树相交的实现逻辑,这里将这条线称之为detail curve。
这里的实现逻辑不需要对二叉树做遍历,只需要组织好数据结构,就可以基于一种递归的思路来得到想要的结果。
对于每一条分支,都用一个红框来标注其下的两条子分支的覆盖范围,当两条分支都已经有了交点,说明不需要再继续往下细分了,如果交点就在末端,那么需要考虑开始着手细分到下一级了,如果没有交点那就要继续往下探索,最终得到这些交点。
为了避免每帧都需要对树进行遍历,可以考虑将交点记录下来,之后每帧更新位置,这种方法快一些,但是实现起来会相对复杂。
总结
性能消耗,不考虑wave particle的话,大约是3.3ms(用的FFT,64x64的分辨率,64帧),加上wave particle就是6ms,不过这里的硬件本身比较差,换到好一点的PC,可能就是1~2ms的消耗。
Wii用的是gerstner wave,覆盖范围更小一点,没有wave particle的时候,大约是0.9ms。
再次总结,一些细节就放在附录里了。
最后一部分是shading。
水体mesh的wireframe效果:
- 采用triangle strip的方式组织,中间的长长的直线是退化三角形,目的是为了实现triangle之间的衔接
- 整体的面数比较低,因为meshing是在CPU计算完成的,所以数据量不能太高
- 整体的mesh的精度并没有随着视距而变得粗糙,这是二叉树方案的后遗症。
这里是环境反射效果(environment map)
添加了两个scrolling的normal map以添加高光效果。
远处的法线数据问题有个bug,没有处理完,这里直接用了一个大的法线贴图来基于距离来做blend,以降低远处的细节表现(参考下图远景效果)。
这是前面说的,第三层法线的效果,用于实现大尺寸的反射、高光效果。
折射效果
添加了浮沫,输入一张海岸的foam map,同时考虑沿着海岸的海浪数据而实现。
这里给了视频,展示了动画效果。
浮沫包含VS/PS两层数据:
- VS层面需要:
- 波形的高度数据
- wave particle的数据
- PS层面需要:
- 贴图数据,比如海岸线等贴图数据
最终浮沫的实现是基于Torres 2012的多层浮沫方案来的,通过修改UV,借用UV动画来得到动画效果。
这里给了一个视频展示基于波形得到的浮沫效果。
水体颜色分为三层,用三种颜色表示,之后基于水体的深度来进行插值。
这里给出了效果的比对,虽然不是非常真实,但看起来还挺像那么回事的。
次表面散射的方案也是基于Torres2012的实现来的
这里是添加了次表面散射的效果。
给了个视频。
经验总计:
- Foam效果还是非常明显的,是肯定需要添加的
- 根据水的深度调整颜色也是必须的
- 在屏幕空间上通过噪声的方式给波形添加细节花了一点时间,但是收益却不明显
- 曾经还想过将高光计算的光照方向跟阴影计算的光照方向分离来实现效果的自由调校,但最后没有成功。
形状的总结:
- 高端机用FFT,低端机用Gerstner wave+parabolic wave
- 通过wave particle来实现交互效果
网格的总结:
- 采用Projected Grid,之后通过Polar Mesh来优化相机移动与旋转过程中的问题(不过到最后也没有介绍说左右移动情况下的问题)
- 适合低端机用,性能看起来不错
Shading的总结
后面可以尝试的优化:
- 在Wii上也采用FFT方案,统一
- 将Polar Mesh逻辑从CPU搬到GPU
整体的演示效果
附录中的具体内容:
- 如何实现对Gerstner wave的高度采样
- 如何采样displacement map
- 如何实现shape的自适应采样(根据设备或者距离的不同?)
- 抛物线方程
- 如何实现wave particle的采样
- 物理模拟的考虑
- detail curve的更多信息
- 如何确保projected grid能够覆盖完整视野(尤其是当Mesh被挤压导致边缘收缩的情况下)
- 其他的保障顶点前后多帧稳定的方案
- 折射相关
通过数值迭代的方式来实现Gerstner wave高度查询。
而FFT实现的displacement map的高度查询,也同样是通过数值迭代的方式来得到。
实现的时候,顶点的排布密度能够跟波形的频率相吻合,从而保证关键信息都是ok的。
这里考虑smoothstep的曲线来实现对波形的模拟
基于这种方式可以实现想要的效果,同时在部分计算时的效率也可以得到保证。
这里给出了抛物线方程的实现细节。
wave particle的高度采样计算方式
多个wave particle的话,采样结果需要叠加。
浮力的考虑比较简单
物理模拟的mesh跟渲染的mesh需要保持一致,而这里的mesh精度由于较低(且跟玩家视角有关系),可能会存在问题
要想保持多个玩家的模拟结果一致,数据就不能是局部的,而得是全局一致的。
关于Detail Curve,这里做了较多的说明
这里介绍了怎么保证mesh能够永远覆盖住屏幕范围,不会穿帮。
这里介绍了左右移动情况下的顶点闪烁问题,不过不是太明白这里的解决方案,感觉好像并不是能很好的解决这个问题。
其实,可以考虑用两套顶点,一套是polar mesh,另一套就是常规的grid mesh,当左右移动的时候就以grid mesh为准,或者直接就用覆盖X-Z两个轴的grid mesh为准,可以同时兼顾移动与旋转等多种情况下的mesh闪烁问题。