背景
水体效果简直是新人 TA 练手的绝佳项目,简单有简单的写法,难有难的写法,网上教程还又多又杂,上手也很方便。而我也恰巧需要一个城市内河流的水效果,所以就准备写一写练手啦。不过后来由于一直对效果不太满意,且 5.0 迁 5.3 的时候出现了很多奇怪的 bug,所以就半途而废了= =。最后项目中计划还是使用 UE 自带的水体效果,不过总之这次尝试还是对让我对水的实现多了很多了解。
水的效果总体而言分为水面、水下和中间分隔的部分。水面部分主要是水体波纹,光线折射和反射,物体周围的涟漪以及水面的泡沫。水下部分一般是对包含了深度雾,焦散等。中间分隔的部分则包含了屏幕分隔以及水线效果。效果见 B 站。
水面
水面主要是参考了国外大佬的一个视频[1],使用的是 SingelLayerWater 材质。SingelLayerWater 自带了水体的折射、光线吸收等,很方便就能做出很好的效果,不过它有单独的渲染通道,对性能的要求较高。
波纹
水波纹的主要是靠法线贴图实现的。这里使用了 Unreal 自带的 water normal,用 Panner 节点随时间循环采样以实现流动的效果。为了增强波纹的随机性,我们使用了两张 water normal,一张的 Tilling(平铺数量)为 4 一张的 Tilling 为 6,将两张 Normal 纹理合并得到最后的水波纹效果。

光线吸收
水面对光线的处理这里依赖了 SingelLayerWater Material 的 AbsorptionCoefficients 吸收系数。不吸收的颜色就会被反射,被人眼捕捉到,所以吸收的颜色是水面颜色反向的颜色,偏蓝绿色的水,吸收的颜色就偏黄偏红。然后再乘以一个非常小的系数以保证大部分光线可以穿透水面,从而达到透明的效果。

折射和涟漪
折射效果也直接使用材质的 refraction 完成,不考虑涟漪的话只需要传入水体折射参数 1.033 即可。而涟漪的效果此处只做了静止物体外圈的涟漪而没做移动效果,所以使用水面离最近物体的距离即可。首先使用 DsiatcenToNormalSerface 获取最近物体的距离,参数 RippleSize 为涟漪的最大距离,超出距离的部分都会因为数值超出 1 被 saturate 处理成 1。取 1-x 使近处大远处小。用时间 Time 加上之前计算的距离的 sin 值在两个折射率之间做 lerp,通过不同的折射率模拟看涟漪下的物体的扭曲效果,乘以波纹数量来控制 sin 值有几个峰谷。

泡沫效果
泡沫效果主要由透明的控制。水的部分是透明的,而泡沫的部分是不透明的白色的。选用两张 Tilling 不同的 noise 贴图来控制泡沫的显示位置。考虑到贴图有灰色部分,可以通过调整它的对比度(cheapContrast 节点)来调整贴图中大于等于1(即显示泡沫)的面积大小。用 Panner 控制 mask 的移动,再用另一张贴图控制显示的泡沫效果。原教程是比较大片的泡沫,但我这里用点状的泡沫比较合适,所以就使用了点状贴图。泡沫的颜色是使用了 noise 函数提供的随机数在较深和较浅的两种白色里做 lerp 得到的效果非纯色效果。将泡沫颜色连到材质的 base color 上,将处理好的泡沫显示结果连到透明度上,即可获得泡沫效果。如果有需要的话可以添加一张 mask 贴图并用 DsiatcenToNormalSerface 控制位置,和之前的 mask 相加,实现在物品和岸边一定距离内添加更多的泡沫的效果。

水下
水下效果一般是使用后处理实现的。在水下部分放一个后处理 volume,镜头到水下后展示后处理效果,镜头在水上时后处理就不起作用。这里主要是参考了b站上一个大佬写的教程[2]再略微加工了一下。
深度雾
深度雾的效果就是距离越远的地方越不清晰,所以直接使用 SceneDepth 获取场景深度,用除法限制最大长度,用幂增加对比度。

暗角
暗角就是在屏幕四个角添加圆弧状压暗的效果。因此我们首先获取屏幕坐标,即左上为原点,右下为正方向,边的长度为1。将数据 - 0.5 再平方,就可以将原点放到屏幕中心,上下左右为 -0.5 - 0.5,再将得到的数值乘以它自己,就可以将负数转为正数,获得中心为数值为 0,上下左右各为 0.25 且向中心渐渐减小的值。之后做了一次 1-x 将中心改为1,外边逐渐减小,这样就可以用幂计算来控制暗角的强度了。幂次越高,暗角的数值越小,暗角越黑且范围越大。

焦散
焦散在原教程中是使用后处理实现的,但是 UE 的后处理使用 absolute world position 会出现效果随相机镜头移动而移动的情况。这个目前全网没有查到非常合适的解决办法。有一种方法是可以将这个材质的 Blandable location 设为 before Tone mapping。但是 Tone Mapping 会调整场景对比度,如果将材质设为 before tone mapping,暗角和中心的对比度会非常夸张且很难调整,所以最后决定换用贴花实现焦散效果。
焦散的核心其实就是焦散贴图(网上贴图太多了没有必要自己做 = =,顺便推荐一个生成贴图的网站 Noise Creater HomePage)。这里我们使用两张一样的贴图,一张移动 x ,一张移动 y。再使用两张 noise 贴图给焦散加上一些抖动效果,以实现焦散被水面波浪影响而抖动的效果。

如果水底是完全水平的,那只需要用 world position 的 x y 作为贴图的 uv 即可。但当地形的法线相差非常大(如同时有水平的水底和边缘比较直的石块)的时候,我们就需要处理贴花边缘拉伸的问题了。一个比较好的方法是参考[3] 的教程,根据三个方向 normal 的大小切换使用的 xy、yz 或 zx 作为贴花的 uv。不过我因为想做着玩所以做了旋转模拟太阳光从一个角度射入水面的效果。基本思路就是使用旋转矩阵将整个坐标系的 z 轴旋转至和太阳光方向相反的方向,这样 world position 的 xy 就不再是水平面,而是和阳光垂直的平面。这样虽然平行于阳光的平面依然会出现拉伸情况,但这样的平面碰到的概率更小,所以总体效果会好一些。旋转矩阵就不赘述了,基本就是法线叉乘获取旋转轴,用 UE 的材质节点获取旋转角度,套矩阵公式即可。
1 | float u = axis.x; |
水面分隔
上下分隔
上下分隔部分是我最终决定使用 UE 自带水效果的决定性因素。这里的上下分隔是用自定义深度(custom depth stencil)实现的,虽然也实现了上下分隔以及水线,但总体效果不尽如人意,而且很容易出现穿帮。这里总体来讲就是记录一下这种实现方法,以及其中的一些坑。
上下分隔的本质就是,水面以上的部分显示的是本来应该渲染的效果,水面以下的部分则显示之前制作的后处理效果。因此要做上下分隔,首先需要在水面上创建一个 “罩子” 区分水面以上和以下。罩子和水面的部分开启自定义深度并给一个自定义深度值,在后处理里获取 CustomStencil 值,CustomStencil 值不为 0 的像素点渲染的就是水面或水上的部分,CustomStencil 值为零的部分则是水下。在后处理中做一次lerp,0 时为水下部分,1 时为获取的渲染画面。将罩子的 render in main pass 和 render in depth pass 设置为 false,render in customerDepth pass 设置为 true,就可以不渲染遮罩但仍然可以在材质的 CustomStencil 中读取它了。

水线
水线就是在当视角部分在水面以上部分在水面以下的时候,在视角中间添加分隔线。分隔线的位置就是 CustomStencil 值变化的位置。因此我们可以使用做模糊时使用的方法提取出分隔线以及上下一定宽度的像素点,从分割线向外数值渐变,再根据数值设置这些像素点的颜色。
具体代码如下所示。这个函数接收半径、偏移值两个参数。半径是做模糊时上下取点的数量,offset 是上下取点时每个点的大小。用 GetDefaultSceneTextureUV()和 SceneTextureLookup()获取视口 texture 的 CustomStencil(第二个参数是选择 scene texture 的类型,25 为 CustomStencil)。计算每个点 y 轴及其上下点的加权平均,减 0.5 再平方,可以得到从分割线向外逐渐变大的宽度为 2 倍半径乘以偏移值的分割线。用分割线的值作为 lerp 的 alpha 值,0 为水线颜色 1 为后处理效果,就可以得到水线的效果了。
1 | float2 UV = GetDefaultSceneTextureUV(Parameters, 25); |
水面波动
水线波动是通过水面的 pixel offset 实现的。将之前水面的 normal 的效果同时连上 pixel offset 的出口,让水面和它的法线以同一频率波动,就可以实现水面和波浪渲染效果同频波动的效果。
坑
用遮罩实现上下分隔有一个非常大的问题,在完成水线后就会特别明显。除非遮罩和地面完全贴合,否则在水下像边缘看的时候就会出现分割线和真正的水体边缘不匹配的效果。这个主要原因就是地形是不规则边缘的,但水面和遮罩一般都是固定形状(一般都用方形,方便= =),这样从下往上看的时候有部分和地形不匹配的遮罩和水面被地形遮挡了,但是 CustomStencil 依然可以识别到数值。这样就会出现类似下图的效果,水下依然能看到水面和遮罩的边缘绘制出来的水线。这个效果对非标准形状的水面会有非常大的影响,所以总体来说除非水效果形状比较标准,否则这套水线的实现方案并不特别适合使用。
