请注意
由于微博图床外链失效,本文部分图片无法获取
介绍与准备
我最近打算开始做毕设项目的场景啦XD!这个项目的名字叫元素宇宙(Elemental Universe),是一个化学元素拟人世界观下的小宇宙的故事,目前只有我一个人在做,第一步是搭场景。主场景打算用polyBrush磨出来,其中有一些湖泊和海洋。海洋部分我打算用GerstnerWave的方法来做,湖泊部分采用本文所介绍的方法(当然,还有很多可以改进的地方。有改进的部分会在“更新说明”里提及)。
废话交代完了,现在正式开始吧:
首先,准备一个细分好的平面(因为我们需要一定数量的顶点)作为水面。在这个项目中我使用的是:
演示模型来自Sketchfab,使用遵守CC版权协议。如下预览所示,模型原本的水面只是一块普通的、smooth值几乎设置为1的水平面:
波浪
波浪的基本原理是顶点动画,通过修改水面顶点y值(高度)实现,这也是为什么前文提到我们需要一块顶点数比较多的面,越多的顶点意味着越高的波浪精细程度。
我们现在希望的事情是:水面能够上下波动形成波浪。对于湖泊这样较为波澜不惊的水域,用GerstnerWave模拟的话浪尖太尖,不太合适。想要圆滑一点的波浪,直接用sin的效果更好。
可以直接对顶点的模型空间坐标的一个分量(譬如x)使用sin函数计算进行模拟,如下所示。其中Height控制浪高,Speed控制波浪起伏速度,Time表示不断递增的时间,vert表示模型空间的顶点。
但如果顶点足够密的话会发现这个波浪是沿着x方向走的,如果把vert.x替换成vert.z就是沿着z方向的浪。相加之后浪就是斜着走的了。我自己是觉得斜着的浪更好看一些吧(直上直下总觉得哪里怪怪的),所以最终采用的公式是:
在shader graph中,它表示为:
最终连接到VertexShader的Position上,波浪搞定。
水域分层与浮沫效果
波浪完成之后,现在的水面是一种单色调的、不透明的、会动的面。
而见过一般水域的你都会认为一个漂亮的水域应该是下图的样子:
这片水域的特点可以概括为以下几点:
- 有微微起伏的起伏
- 水域分层,浅层的水的颜色也更透亮、深层的水则更深沉
- 岸边伴随着白色的浮沫
- 水下的物体由于光的折射而扭曲
其中波浪部分我们已经实现了,现在开始实现水域分层。
水域分层
水域分层体现在越浅的水的颜色越透亮、越深的水颜色更深沉,颜色与深度有关。
如果获取到水域的深浅情况,就可以根据水深对颜色进行插值了。怎么求水深呢?假设水面顶点经过MV矩阵处理完的坐标为现在我们有:
- 相机的深度图(Z-buffer),一张黑白的图片,表示每个绘制出来的像素的深度值
- 原始(raw)屏幕空间位置的数据,从相机出发获取每一点的世界空间深度
这里Z-Buffer里的值是非线性的,需要转换成眼空间下的线性深度值进行计算,在ShaderLab中使用的是LinearEyeDepth函数。如果我们把眼空间的场景深度减去原始(raw)屏幕空间位置的alpha值,就能获取到一个差值对深层水的颜色与浅层水的颜色进行插值。LinearEyeDepth的底层推导过程可以参考:https://zhuanlan.zhihu.com/p/157863844
如果你觉得这个插值太刚硬了,可以加一个指数去调节,让颜色的过渡更平滑(这是我在刚做这个效果时没意识到的一个地方)。或者可以干脆新添加一个变量“陡峭程度”steep:
目前为止,fragment部分的shader graph长下图这样(请不要在意那些多出来的线,与后续步骤有关)。现在我们要在水域分割的基础上添加浮沫的效果啦。
请注意
如果要获取深度图,需要在相机渲染设置里开启Depth Texture选项
ScreenPosition节点的模式
default模式
光栅化的章节里我简单画了一个屏幕坐标系,当时以屏幕左下角为原点建系:
事实上,default模式的屏幕空间坐标系也是这样建的。如果把ScreenPosition连到baseColor上输出,就能得到上图右所示的情况。该模式下节点只包含了屏幕上任何一像素的横纵坐标值。
center模式
center模式较于default模式将原点移动到了屏幕中心,该模式下节点依然只包含了屏幕上任何一像素的横纵坐标值。
tile模式
“tile”的意思是平铺。以屏幕中心为原点,每256*256个像素作为一个单元进行平铺,该模式下节点依然只包含了屏幕上任何一像素的横纵坐标值。
raw模式
raw模式即原始数据模式,与前面提到的三种模式都不同。raw模式是数据指每个顶点经过MVP矩阵变换之后、还未进行齐次除法的信息。第四个分量,或者说,代表了世界空间的深度,或者说眼空间深度(eye-space depth)。
为什么?因为:
- 顶点数据在MV矩阵处理过后为,其中w分量不受平移、旋转、缩放的影响,依然是1。这里的z的绝对值代表了点到相机的深度距离。
- 投影矩阵P为:
- 经过计算,顶点坐标变成了。
- 可见变成了点到相机的深度距离,或者说眼空间深度(eye-space depth)。
在正交模式下,由于,始终为1。
可见这个结果与顶点自身有关,因为波浪中控制顶点进行运动,所以将ScreenPosition连到baseColor上输出的话结果是在不停地改变着的:
浮沫
浮沫(或者说白沫)指的是水域靠近岸边时产生的一系列白色泡沫边缘部分,常见于海边,适当的浮沫会让水域更生动。在前面的水域划分部分里,其实我们已经得到了被着色为浅水的部分,这部分正好对应岸边“应当聚集浮沫”的地方。那我们拿这个区域与浮沫的贴图相乘不就好了吗?没错。
可以找心仪的噪声来模拟波浪,不同的噪声可以获得不一样的波浪效果:
首先,我们要让浮沫能够**“动起来”。在把噪声纹理导入之后,使用Time节点修改纹理的UV偏移量就能实现。因为波浪的方向是斜着的,所以直接乘Time就行。这里我们可以给Time乘一个浮沫速度的变量以控制噪声的运动速度**。同时,可以加一个变量浮沫缩放用来控制噪声的缩放大小。在Shader Graph中可以直接用TileAndOffset节点实现:
请注意
如果要得到正确的、连续的贴图偏移/缩放效果,需要在贴图设置里修改平铺模式为Reapeat
现在,让噪声与浮沫水域(和浅水域很类似,但你可以自己定义一个FoamArea参数取代原本的Depth用来控制浮沫范围)部分相乘,得到的结果再与浮沫颜色根据浮沫水域范围进行插值(用来控制噪声的消隐):
好丑啊!!!本身就模糊的噪声被直接贴上来了,各种意义上都有失美感。我们可以用Step函数滤掉一部分,获得一个较硬的浮沫边缘:
已经搞定了,不是吗?而且由于我们设置了独立于浅水域的浮沫水域,可以设置浮沫的范围:
水体折射
现在的水体虽然已经能够展示浮沫和深浅水域了,但是我们没法透过水域看到湖底的东西,也没有折射效果。根据光学原理,光在穿过不同介质的时候会发生折射现象,加上水面的扭曲,我们看到的东西不会是这么宁静的。所以我们要做的事情是:
- 增加折射效果(晃动水底)
- 增加扭曲效果(噪声扭曲)
晃动水底
把Default模式的ScreenPosition加上SceneColor节点之后、与浅水域颜色进行插值再输出到水面上,会得到水底的环境(现在我们终于能看清这条鱼长什么样子了):
既然要晃动水底实现折射的效果,理所应当的我们会想到使用一个周期函数进行摇晃,而最常见的周期函数就是sin。把Time节点的sin(需要自行调整一下大小)与ScreenPosition相加之后,得到了这样的效果:
我们的水面也没有晃的那么厉害,折射所看到的box的虚影应该至少在晃动的同侧。可以把sin函数的范围从[-1,1]映射到[0,1],保证同侧:
噪声扭曲
现在就是最后一步,也就是扭曲水底的映像,得到水体波动扭曲的效果。实现方法是通过噪声生成法线(NormalFromHeight节点),与ScreenPosition节点相加,再输出到片元着色器的base color节点上。诶,为什么Normal不是输出到Normal节点呢?因为NormalFromHeight节点使用的是屏幕空间求导得到节点(这也是为什么我们能把ScreenPosition与之相加),这在顶点着色器中不可用。
我们几乎已经全部完成了。现在,把水体结果与水域分割、浮沫相加,再用saturate规范一下(因为我发现浮沫在开启bloom效果的情况下特别亮,说明有值太大了):
更新说明
2022/9/15 - 让水域分层面一起扭曲
分层的面没有和折射的部分一起扭曲,显得很奇怪,于是我把用来扭曲折射的部分(加到SceneColor前面的那一大串)加到了深度节点的uv接口。
2022/9/15 - 为什么不加上焦散呢?
焦散(Caustic)是一种光学现象。当观察游泳池或者其他清澈透亮的水体的时候,很容易在底部或者壁面发现这样的光纹,这就是焦散:
A caustic is the envelope of light rays which have been reflected or refracted by a curved surface or object, or the projection of that envelope of rays on another surface.
焦散是由曲面引起的光反射。一般来说,任何曲面都可以表现得像一个透镜,将光线聚焦在一些点上,并将其散射到其他点上。玻璃和水是允许它们形成的最常见的介质.
采用这篇博客提供的方法,可以通过一张贴图模拟出逼真的焦散效果:https://www.alanzucconi.com/2019/09/13/believable-caustics-reflections/
两次以不同程度的偏移来采样焦散噪声,并用min函数对两张贴图进行混合:
让焦散的部分展示在浅水域,同时在uv采样时加上扭曲就ok力!