请注意
由于微博图床外链失效,本文部分图片无法获取

介绍与准备

我最近打算开始做毕设项目的场景啦XD!这个项目的名字叫元素宇宙(Elemental Universe),是一个化学元素拟人世界观下的小宇宙的故事,目前只有我一个人在做,第一步是搭场景。主场景打算用polyBrush磨出来,其中有一些湖泊和海洋。海洋部分我打算用GerstnerWave的方法来做,湖泊部分采用本文所介绍的方法(当然,还有很多可以改进的地方。有改进的部分会在“更新说明”里提及)。

废话交代完了,现在正式开始吧:

首先,准备一个细分好的平面(因为我们需要一定数量的顶点)作为水面。在这个项目中我使用的是:

演示模型来自Sketchfab,使用遵守CC版权协议。如下预览所示,模型原本的水面只是一块普通的、smooth值几乎设置为1的水平面:

波浪

波浪的基本原理是顶点动画,通过修改水面顶点y值(高度)实现,这也是为什么前文提到我们需要一块顶点数比较多的面,越多的顶点意味着越高的波浪精细程度。

我们现在希望的事情是:水面能够上下波动形成波浪。对于湖泊这样较为波澜不惊的水域,用GerstnerWave模拟的话浪尖太尖,不太合适。想要圆滑一点的波浪,直接用sin的效果更好。

可以直接对顶点的模型空间坐标的一个分量(譬如x)使用sin函数计算进行模拟,如下所示。其中Height控制浪高,Speed控制波浪起伏速度,Time表示不断递增的时间,vert表示模型空间的顶点。

vert.y+=Heightsin(vert.xTimeSpeed)vert.y += Height * sin(vert.x * Time * Speed)

但如果顶点足够密的话会发现这个波浪是沿着x方向走的,如果把vert.x替换成vert.z就是沿着z方向的浪。相加之后浪就是斜着走的了。我自己是觉得斜着的浪更好看一些吧(直上直下总觉得哪里怪怪的),所以最终采用的公式是:

vert.y+=Heightsin((vert.x+vert.z)TimeSpeed)vert.y += Height * sin((vert.x + vert.z) * Time * Speed)

在shader graph中,它表示为:

image

最终连接到VertexShader的Position上,波浪搞定。

水域分层与浮沫效果

波浪完成之后,现在的水面是一种单色调的、不透明的、会动的面。

而见过一般水域的你都会认为一个漂亮的水域应该是下图的样子:

我想去度假

这片水域的特点可以概括为以下几点:

  1. 有微微起伏的起伏
  2. 水域分层,浅层的水的颜色也更透亮、深层的水则更深沉
  3. 岸边伴随着白色的浮沫
  4. 水下的物体由于光的折射而扭曲

其中波浪部分我们已经实现了,现在开始实现水域分层。

水域分层

水域分层体现在越浅的水的颜色越透亮、越深的水颜色更深沉,颜色与深度有关。

海洋调色盘, created by Sherwin-Williams

如果获取到水域的深浅情况,就可以根据水深对颜色进行插值了。怎么求水深呢?假设水面顶点经过MV矩阵处理完的坐标为(xyz1)\begin{pmatrix}x \\y \\z \\1 \end{pmatrix}现在我们有:

  1. 相机的深度图(Z-buffer),一张黑白的图片,表示每个绘制出来的像素的深度值z(Far+Near)+2NearFarz(FarNear)+12\frac{\frac{z(Far + Near)+2 \cdot Near \cdot Far}{z(Far - Near)}+1}{2}
  2. 原始(raw)屏幕空间位置的数据,从相机出发获取每一点的世界空间深度z-z

这里Z-Buffer里的值是非线性的,需要转换成眼空间下的线性深度值进行计算,在ShaderLab中使用的是LinearEyeDepth函数。如果我们把眼空间的场景深度减去原始(raw)屏幕空间位置的alpha值,就能获取到一个差值对深层水的颜色与浅层水的颜色进行插值。LinearEyeDepth的底层推导过程可以参考:https://zhuanlan.zhihu.com/p/157863844
image

如果你觉得这个插值太刚硬了,可以加一个指数去调节,让颜色的过渡更平滑(这是我在刚做这个效果时没意识到的一个地方)。或者可以干脆新添加一个变量“陡峭程度”steep:

steed

目前为止,fragment部分的shader graph长下图这样(请不要在意那些多出来的线,与后续步骤有关)。现在我们要在水域分割的基础上添加浮沫的效果啦。

片元着色器图

请注意
如果要获取深度图,需要在相机渲染设置里开启Depth Texture选项

ScreenPosition节点的模式

default模式

光栅化的章节里我简单画了一个屏幕坐标系,当时以屏幕左下角为原点建系:
屏幕坐标系

ScreenPosition的default模式

事实上,default模式的屏幕空间坐标系也是这样建的。如果把ScreenPosition连到baseColor上输出,就能得到上图右所示的情况。该模式下节点只包含了屏幕上任何一像素的横纵坐标值。

center模式

center模式较于default模式将原点移动到了屏幕中心,该模式下节点依然只包含了屏幕上任何一像素的横纵坐标值。

image

tile模式

“tile”的意思是平铺。以屏幕中心为原点,每256*256个像素作为一个单元进行平铺,该模式下节点依然只包含了屏幕上任何一像素的横纵坐标值。

ScreenPosition的tile模式

raw模式

raw模式即原始数据模式,与前面提到的三种模式都不同。raw模式是数据指每个顶点经过MVP矩阵变换之后、还未进行齐次除法的信息。第四个分量ww,或者说aa,代表了世界空间的深度,或者说眼空间深度(eye-space depth)。
为什么?因为:

  1. 顶点数据在MV矩阵处理过后为(xyz1)\begin{pmatrix}x \\y \\z \\1 \end{pmatrix},其中w分量不受平移、旋转、缩放的影响,依然是1。这里的z的绝对值代表了点到相机的深度距离。
  2. 投影矩阵P为:

P=(cotFOV2Ascept0000cotFOV20000Far+NearFarNear2NearFarFarNear0010)P = \begin{pmatrix} \frac{\cot{\frac{FOV}{2}}}{Ascept} & 0 & 0 & 0 \\ 0 & \cot{\frac{FOV}{2}} & 0 & 0 \\ 0 & 0 & -\frac{Far + Near}{Far - Near} & -\frac{2 \cdot Near \cdot Far}{Far - Near} \\ 0 & 0 & -1 & 0 \end{pmatrix}

  1. 经过计算,顶点坐标变成了(xcotFOV2AsceptycotFOV2z(Far+Near)2NearFarFarNearz)\begin{pmatrix}x { \frac{\cot{\frac{FOV}{2}}}{Ascept}} \\y{\cot{\frac{FOV}{2}}} \\\frac{-z(Far + Near)-2 \cdot Near \cdot Far}{Far - Near} \\-z \end{pmatrix}
  2. 可见ww变成了点到相机的深度距离,或者说眼空间深度(eye-space depth)。

在正交模式下,由于Porth=(1AsceptSize00001Size00002FarNearFar+NearFarNear0001)P_{orth} = \begin{pmatrix}\frac{1}{Ascept \cdot Size} & 0 & 0 & 0 \\0 & \frac{1}{Size} & 0 & 0 \\0 & 0 & -\frac{2}{Far - Near} & -\frac{Far + Near}{Far - Near} \\0 & 0 & 0 & 1\end{pmatrix}ww始终为1。

可见这个结果与顶点自身有关,因为波浪中控制顶点进行运动,所以将ScreenPosition连到baseColor上输出的话结果是在不停地改变着的:

raw

浮沫

image

浮沫(或者说白沫)指的是水域靠近岸边时产生的一系列白色泡沫边缘部分,常见于海边,适当的浮沫会让水域更生动。在前面的水域划分部分里,其实我们已经得到了被着色为浅水的部分,这部分正好对应岸边“应当聚集浮沫”的地方。那我们拿这个区域与浮沫的贴图相乘不就好了吗?没错。

可以找心仪的噪声来模拟波浪,不同的噪声可以获得不一样的波浪效果:

foam1foam2

首先,我们要让浮沫能够**“动起来”。在把噪声纹理导入之后,使用Time节点修改纹理的UV偏移量就能实现。因为波浪的方向是斜着的,所以直接乘Time就行。这里我们可以给Time乘一个浮沫速度的变量以控制噪声的运动速度**。同时,可以加一个变量浮沫缩放用来控制噪声的缩放大小。在Shader Graph中可以直接用TileAndOffset节点实现:

noise

请注意
如果要得到正确的、连续的贴图偏移/缩放效果,需要在贴图设置里修改平铺模式为Reapeat
image

现在,让噪声与浮沫水域(和浅水域很类似,但你可以自己定义一个FoamArea参数取代原本的Depth用来控制浮沫范围)部分相乘,得到的结果再与浮沫颜色根据浮沫水域范围进行插值(用来控制噪声的消隐):

好丑啊!!!本身就模糊的噪声被直接贴上来了,各种意义上都有失美感。我们可以用Step函数滤掉一部分,获得一个较硬的浮沫边缘:

已经搞定了,不是吗?而且由于我们设置了独立于浅水域的浮沫水域,可以设置浮沫的范围:

area

水体折射

现在的水体虽然已经能够展示浮沫和深浅水域了,但是我们没法透过水域看到湖底的东西,也没有折射效果。根据光学原理,光在穿过不同介质的时候会发生折射现象,加上水面的扭曲,我们看到的东西不会是这么宁静的。所以我们要做的事情是:

  1. 增加折射效果(晃动水底)
  2. 增加扭曲效果(噪声扭曲)

晃动水底

把Default模式的ScreenPosition加上SceneColor节点之后、与浅水域颜色进行插值再输出到水面上,会得到水底的环境(现在我们终于能看清这条鱼长什么样子了):

挺可爱的

既然要晃动水底实现折射的效果,理所应当的我们会想到使用一个周期函数进行摇晃,而最常见的周期函数就是sin。把Time节点的sin(需要自行调整一下大小)与ScreenPosition相加之后,得到了这样的效果:

sin

我们的水面也没有晃的那么厉害,折射所看到的box的虚影应该至少在晃动的同侧。可以把sin函数的范围从[-1,1]映射到[0,1],保证同侧:

image

噪声扭曲

现在就是最后一步,也就是扭曲水底的映像,得到水体波动扭曲的效果。实现方法是通过噪声生成法线(NormalFromHeight节点),与ScreenPosition节点相加,再输出到片元着色器的base color节点上。诶,为什么Normal不是输出到Normal节点呢?因为NormalFromHeight节点使用的是屏幕空间求导得到节点(这也是为什么我们能把ScreenPosition与之相加),这在顶点着色器中不可用。

扭曲 着色器图

我们几乎已经全部完成了。现在,把水体结果与水域分割、浮沫相加,再用saturate规范一下(因为我发现浮沫在开启bloom效果的情况下特别亮,说明有值太大了):

目前的效果 着色器图

更新说明

2022/9/15 - 让水域分层面一起扭曲

almost change

分层的面没有和折射的部分一起扭曲,显得很奇怪,于是我把用来扭曲折射的部分(加到SceneColor前面的那一大串)加到了深度节点的uv接口。

2022/9/15 - 为什么不加上焦散呢?

change2

焦散(Caustic)是一种光学现象。当观察游泳池或者其他清澈透亮的水体的时候,很容易在底部或者壁面发现这样的光纹,这就是焦散:

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力!