今天分享的是开源水体渲染方案Crest在Siggraph 2017上分享的技术实现细节。
先对相关要点做个总结:
相关细节给出如下。
主讲人Huw Bowles之前分享过基于polar mesh的projected grid。
水体渲染方案有如下几个挑战点:
- 实现水波上的相关细节
- 水波的运动要足够真实
- 支持与跟水体接触的物件的交互
水体的渲染跟之前总结的一样,分为波形模拟(shape)、Mesh组织以及Shading三部分,其中前面两部分是彼此关联的,不应该分开介绍。
下面先来看下mesh数据如何组织。
这里是对mesh的目标:
- 顶点数(面数)越少越好
- 支持LOD
- 顶点数据是稳定的,不会因为视角、LOD的切换而跳变(看来是projected grid留下的伤痕太深了)
这里采用的策略是Clipmap方案,多级clipmap之间的衔接可以参考Hoppe的实现
McGuire给出了一种实现思路可以不用考虑多级Clip之间的衔接(从而可以一个DP绘制完成),这种思路就是在相邻两级clipmap之间保留一定的overlap,比如将更精细的一级往外扩展一部分,同时保证多出来的顶点的高度跟相邻两个顶点插值高度相等即可,具体参考文档
这是clipmap的wireframe效果,每一级clip都会被分割成多个tile(从示意图可知,不同clip的tile覆盖范围是不同的,应该有一个倍数关系,看图应该是两倍)
这是移动后的debug效果,通过不同颜色标注不同的mesh lod。这里用了McGuire的Mesh Overlap策略,不过可以看到,会存在深度竞争问题(看来魔法并没有那么完美,解决了裂缝问题,会引入深度竞争穿插问题)。
因为深度竞争问题,因此这里并不想继续采用McGuire的overlap策略,而是希望采用类似CDLOD的连续形变思路,不过mesh还是Clipmap方案。
精度较高的一级,会随着到相机的距离的增加,而不断加大顶点merge的力度,直到跟粗粒度的mesh接壤的时候,达到完全匹配的状态。
通过这种方式可以实现顶点精度的连续变化,从而避免移动过程中因为LOD切换导致的顶点跳变问题
这里以单个clip来进行解释,通过观察可以发现,其形变策略给出如下:
- 只取奇序的顶点进行形变
- 奇序顶点队列中,奇数号往左(上),偶数号往右(下)
以4x4个顶点为一个section,这里给出了水平方向上各个点的移动说明
这个方法看起来似乎可以,但是如果我们把视角拉高就会发现几个问题:
- 水体边缘锯齿比较明显
- 高视角下可以看到,在低视角下有很多比较密集的区域是被遮挡的,浪费的。
第一个问题的解决方案是将最外部的顶点往一个更远的地方延伸,从而避免前面说的锯齿问题(这里的前提是距离是足够远的,不能是近岸边缘)
针对第二个问题的解法是,根据相机的高度来对mesh的顶点密度进行缩放,随着相机的拉高,逐渐降低mesh的密度,而在相机处于低位的时候,就提高mesh的精度以视角较远时的高精浪费。
如果采用的是平滑的scale方法,会导致顶点的波动,这个会导致水波的跳变,而由于clipmap各级之间的网格密度是2的倍数,因此这里的做法是做成阶跃函数,到达一个阈值时,就将密度按照2的倍率进行缩放,这个能够解决大部分问题,但是对于第一级跟最后一级clipmap,还是会存在问题。
第一级LOD采用这种step的切换方法会存在明显的跳变,这里的做法是将第一级的切换从step改成平滑的fade。
最后一级,在视角较低的时候,直接用前面说到的延伸到无穷远的mesh覆盖替代。
McGuire的Overlap方案在地形渲染上可能有效,但是在水体渲染上经验证发现是得不偿失的。
但是这里发现,如果对tile做精细设计的话,是可以通过9种不同的tile来实现相邻clip之间overlap的平滑的(具体细节这里没有介绍)。
为了能够将高精的LOD0覆盖尽可能多的屏幕像素,这里还做了一个trick,即给相机添加一个跟随的sphere,sphere的半径跟相机的高度有关,相机越高,半径越大,从而使得相机拉高了之后,LOD0的覆盖范围(应该是整个mesh的覆盖范围)也随之扩大,从而避免高精mesh的浪费。
效果展示。
CDLOD是神海4所用的方案,基于距离进行切换,DP数等于LOD数
两者用于LOD切换的公式是不同的,CDClipmap用的taxicab距离,因此iosline是方形的,而CDLOD则是圆形的(更好)。
CDLOD需要在每一帧决定每个四叉树node的LOD层级,而CDClipmap则是在初始化的时候设计好了,后面只需要GPU根据参数进行fade即可。
CDClipmap还有一个优点,就是mesh的LOD等级覆盖的区域的形状跟FFT的wave texture的形状是一致的(可能也没那么重要)。
接下来看看波形。
有了mesh之后,接下来需要介绍mesh上的顶点该如何在运行时驱动。
简单来说,就是有一张displacement贴图,如上图所示,有三个通道,分别代表顶点在XYZ三个方向上的偏移。
那么这张displacement贴图是怎么得到的呢?
这张贴图是在运行时生成的,会将需要模拟的区域,分割成一个个的tile,每个tile执行一遍shape shader,计算结果写入到shape对应的贴图的tile中。《深海4》用的也是类似思路(将粒子的形变数据写入到offset贴图中)
每一级LOD都会对应于一张displacement map,绘制的时候,相机采用的是正交投影,正好覆盖前面clipmap的范围,对于两级之间的缝合区域,会在shader中对两张贴图进行采样并混合。
这里没有采用mipmap,因为要生成覆盖各个区域的mip0成本比较高。
这里提供了一个设置:每个wave绘制的最少顶点数(左上角的滑动条)。通过这个配置可以让美术同学很直观的知道缩小面数带来的效果差异,可以更好的在兼顾表现的前提下优化性能。
Shape Shader是啥,其实所有能够生成displacement贴图的shader都可以,比如左边列举的这些波形计算算法。
实际使用中可能是其中的一种或者多种的组合,接下来会对其中的实现细节做一下展开。
FFT直接看这个链接即可。
Gerstner Wave是一种用多种正弦波叠加来模拟水波的方法,这种方法是可以通过过滤高频信号来解耦视觉效果跟物理模拟(gameplay数据)的。
这里介绍了Wave Particle的应用细节,采用了一个叫做cheapstep的简化函数来代替smoothstep(指令数从三变成二)的方法
波形模拟走离线,还是运行时?
运行时模拟的一种常用简化手段,是将3D的水体模拟精简为2D的。
其中:
- 2D Wave Equation可以用低成本提供一种波形的感觉,但是距离物理正确差的有点远。
- 浅水方程(SWE)的模拟效果更为物理,当然,消耗也更高
模拟的一个问题是,我们是应该在LOD0上模拟,还是应该在所有的LOD上做模拟?
这里给出了一个波形实现的效果,方案为给水体表面添加一个力,使之产生一个能够匹配移动效果的波形,之后将这个波形叠加到水体的原始波形上,形成一个受移动物体影响的波形效果。
另外一个演示视频,方案同上
如果是一个带有动画的角色或者物体,其产生的水波影响则可以通过在maya等建模软件中提前设计而得到
这里介绍了另一个用类似思路来实现breaking wave的视频(方案),不过breaking wave不仅仅需要驱动的数据,还需要保证wave处的顶点密度要足够高(对应于这里的方案,在需要播剧情的时候,可以考虑将波形的中心放到角色身上)。
任何可以通过数学公式表达的形状,都可以通过波形方案模拟出来。
一般来说,添加一些噪声效果会更好。
这里就实现了一个角色避水的效果。
这里介绍了水珠脱离水面的一些效果实现方案,通过特效来实现,会继承水面的速度并稍作减损(能量守恒)
一些高频的信号,通过波形不一定能表达(顶点密度不足),这时候就要借助于表面法线来实现。
法线的采样如果不做缩放, 那就可能出现上图的的tiling与远处的高频闪烁噪声。说到底就是缺了normal mipmap的逻辑,也就是随着距离的拉远,法线应该采样一些低频的信号来避免重复与噪声。
最后来看下着色部分,包括foam跟depth peeling。
foam效果
foam的计算逻辑还是一如既往的,基于displacement贴图的雅克比矩阵的行列式值来定,当小于1的时候,表明水面是压缩的,需要生成foam。
为了保持foam的稳定,需要存储上一帧的foam效果并与当前帧做叠加,当然,叠加之前需要对上一帧的数据做衰减,这里可以通过ping-pong buffer的方式来完成数据的存储与复用。
foam的效果会通过前面计算的数值从贴图采样得到,采样时需要对UV做求余计算。
需要注意的是,foam有水上(白色)跟水下(淡蓝色)两种。
(会跟随foam的强度来动态调整black-point的fade数值?)
没有foam
水上foam
水下foam
两种foam(效果在开源的unity项目中可以看到)
Depth Peeling是一种OIT(order independent transparency)方案,基于这种方案可以实现复杂的光照路径:光线在水体-空气中经历多次穿梭。
关闭时的效果是不正确的,水体背后的体积光散射效果都是错的
这是因为水体渲染的时候,并不能感知到背后的数据。正确的效果如上图所示。
这里大概介绍了depth peeling的实现思路,即按照从后往前的方式进行多次渲染,保留背后的光照数据,之后绘制最上层,将前面layer的数据叠加并应用起来,得到相对正确的效果。
Depth Peeling是一种经典的OIT方案,可以有效解决半透物体穿插导致的效果异常,不过因为需要多个pass,所以性能较差
相机跟水面的相对位置如上图所示
其中红色部分是最前面的表面。
也就是说,这部分表面对应的水体并不会覆盖相机
接着将depth pass设置为greater,意味着背后的surface会被绘制(橙色)
不会将第一段橙色背后的蓝色数据保留下来吗?Depth Peeling算法会通过一个额外的Depth RT存储上一帧的depth数据,之后通过shader采样这个depth比对,当小于(更远)即存储,否则就丢弃,同时借助硬件自带的depth test,保证得到的用于是后续多层中最接近相机的一层。
这里会通过stencil标记出哪些是需要peel depth的区域
基于前后两次的绘制,计算中间的光照scatterring
接着继续peeling,直到完成
完成后,得到多层的数据。
接着绘制最前面一层,叠加此前的数据,完成折射、光照散射以及焦散等效果。
左边只有front layer,右边则是depth peeling效果
性能跟层数有关,上面给出了具体的数据,虽然在运行时不能多用,不过或许可以考虑用在一些特殊场景(比如剧情?)
使用到了上述RT
多层效果叠加的视频
最后对方案做个总结
这里给出了最终的性能数据(没有应用场景验证,没有美术同学调校,不敢说质量一流),还有一些优化点没有做
这里做个总结,下面是附录。
首先是对Projected Grid的介绍。
河流等局部水体可以通过对海洋水体的剔除来实现,常用的mesh表达方案是CDLOD(神海4)
Depth Peeling细节,前面已经介绍过了