US4L8-4——水波效果

水波效果

水波效果指在计算机图形学中模拟水面波纹的视觉效果,通常用于游戏、动画或者其他虚拟场景中。
主要用于体现水体的动态感,比如水的波动、反射、折射、透明等,可以让人感觉像真实的水一样流动闪耀。

水波效果的核心特点就是:

  1. 动态波纹
  2. 光学特性(反射、折射、菲涅耳效应)
  3. 透明度

等等

image

水波效果基本原理

一句话总结水波效果基本原理:水波效果可以基于我们之前实现的带法线纹理的玻璃效果进行修改,通过添加噪声法线纹理结合 Shader 内置时间变量实现水波动态效果,加入菲涅耳计算公式实现水面的光学特性

关键点:

  • 噪声纹理的使用

    我们可以利用沃利噪声(细胞噪声)生成的噪声纹理灰度图,在 Unity 中将该噪声纹理灰度图作为高度图使用
    用它代表水面的法线信息,只需要在 Unity 中将该灰度图设置为 Normal map,并勾选 Create from Grayscale 后应用即可

    image

    imageimage

  • 动态效果的实现

    自定义两个属性,代表水平面 x 和 y 轴的速度。在片元着色器中利用 Shader 内置时间参数 _Time.y​ 得到累积速度变化。
    然后用该速度变量从噪声法线纹理中进行两次采样,再讲两次采样的结果相加得到扰动后的法线,
    最后用该法线处理折射、反射、菲涅耳效果,这样看起来就会有动态效果了

    float4 _Time​ 是内置的时间变量,4个分量的值分别是 (t/20, t, 2t, 3t)​,具体详见:US3S9L1——如何制作动态效果

    1
    2
    3
    4
    5
    6
    7
    8
    // 加入一个水波移动速度的变量
    float speed = _Time.y * float2(_WaveXSpeed, _WaveYSpeed);
    // 由原本的法线相关计算,改为从噪声法线中进行偏移获取,+和-的目的是为了生成一个更加平滑且动态变化的法线扰动,模拟水波的真实感
    // 通过让UV坐标沿相反方向移动,分别计算两个独立的动态法线扰动,这样可以模拟水波的波浪效果,即一个播放和一个波谷的反向运动
    fixed3 bump1 = UnpackNormal(tex2D(_BumpMap, i.uv.zw + speed)).rgb;
    fixed3 bump2 = UnpackNormal(tex2D(_BumpMap, i.uv.zw - speed)).rgb;
    // 将两个动态扰动法线叠加在一起,形成一个综合的扰动效果。这样会让法线扰动更加复杂且自然,避免单一扰动的生硬感
    fixed3 bump = normalize(bump1 + bump2);

    该算法是图形学前辈们总结的高效的模拟流动感的算法,水波、火焰、玻璃折射都可以用

  • 菲涅耳公式的运用

    我们只需要将之前学习过的菲涅耳近似公式在最后的颜色计算中进行运用,便可以让水面呈现出菲涅耳现象效果
    菲涅尔反射相关,详见:US3S8L6——菲涅尔反射

    1
    2
    // 根据schlick菲涅尔近似公式,计算菲涅尔反射率
    fixed fresnal = _FresnelScale + (1 - _FresnelScale) * pow((1 - dot(normalize(i.worldViewDir), normalize(i.worldNormal))), 5);

水波效果具体实现

使用如下的水面颜色纹理和噪声纹理来实现水面效果:

Water_Diffuse​​water_noise

  1. 新建 Shader,命名为 WaterWave​,复用带法线的玻璃效果 Shader

    代码详见:US3S8L9——玻璃效果 中的 带法线的玻璃效果 Shader

  2. 属性相关

    1. 添加用于控制动态效果的 x 和 y 轴方向的速度属性

      1. _WaveXSpeed
      2. _WaveYSpeed
    2. 将玻璃种的折射属性删除,改为之后菲涅耳效果会用到的反射率

      1. 删除 _RefractAmount
      2. 添加 _FresnelScale

    修改对应的属性映射

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    Properties
    {
    _MainTex("MainTex", 2D) = ""{} // 主纹理
    _BumpMap("BumpMap", 2D) = ""{} // 法线纹理
    _Cube("Cubemap", Cube) = ""{} // 立方体纹理
    _Distortion("Distortion", Range(0, 10)) = 0 // 控制扭曲程度的变量
    _WaveXSpeed("WaveXSpeed", Range(-0.1, 0.1)) = 0.01 // 控制水波水平速度偏移的属性
    _WaveYSpeed("WaveYSpeed", Range(-0.1, 0.1)) = 0.01 // 控制水波竖直速度偏移的属性
    _FresnelScale("FresnelScale", Range(0, 1)) = 1 // 菲涅尔反射率
    }
  3. 修改片元着色器

    1. 将原来的法线相关采样 修改为扰动算法(对噪声法线纹理进行 +​ 和 -​ 偏移采样,采样后叠加单位化)

      1
      2
      3
      4
      5
      6
      7
      // 加入一个水波移动速度的变量
      float2 speed = _Time.y * float2(_WaveXSpeed, _WaveYSpeed);
      // 通过让UV坐标沿相反方向移动,分别计算两个独立的动态法线扰动,这样可以模拟水波的波浪效果,即一个播放和一个波谷的反向运动
      fixed3 bump1 = UnpackNormal(tex2D(_BumpMap, i.uv.zw + speed)).rgb;
      fixed3 bump2 = UnpackNormal(tex2D(_BumpMap, i.uv.zw - speed)).rgb;
      // 将两个动态扰动法线叠加在一起,形成一个综合的扰动效果。这样会让法线扰动更加复杂且自然,避免单一扰动的生硬感
      fixed3 bump = normalize(bump1 + bump2);
    2. 修改折射相关计算,将对应法线换为新法线

      1
      2
      3
      4
      5
      // 折射颜色获取(即从物体遮挡的后边的屏幕内容采样,获取类似透明效果的颜色)
      float2 offset = bump.xy * _Distortion; // 在采样前计算xy屏幕坐标的偏移量
      i.grabPos.xy = offset * i.grabPos.z + i.grabPos.xy; // 用偏移量和屏幕空间深度值相乘,模拟出真实的折射效果
      float2 screenUV = i.grabPos.xy / i.grabPos.w; // 利用透视除法,将屏幕坐标转换到0~1范围内,然后再从屏幕纹理内采样
      fixed4 grabColor = tex2D(_GrabTexture, screenUV);
    3. 修改反射相关计算,世界空间下法线用扰动后发现进行计算,使用它计算反射相关内容

      1
      2
      3
      4
      5
      6
      7
      8
      // 将切线空间下法线数据转换到世界空间下
      float3 worldNormal = float3(
      dot(i.tangentToWorld0.xyz, bump),
      dot(i.tangentToWorld1.xyz, bump),
      dot(i.tangentToWorld2.xyz, bump)
      );
      // 根据逆向视角方向和法线纹理内得到的法线纹理贴图计算反射向量
      float3 reflection = reflect(-viewDir, worldNormal);
    4. 处理菲涅耳效应,复制相关菲涅耳近似公式代码,用计算出来的系数处理最终的折射和反射相关内容

      1
      2
      3
      4
      // 根据schlick菲涅尔近似公式,计算菲涅尔反射率
      fixed fresnal = _FresnelScale + (1 - _FresnelScale) * pow((1 - dot(normalize(viewDir), normalize(worldNormal))), 5);
      // 通过折射程度,控制反射颜色和屏幕颜色的叠加,得到最终颜色
      fixed4 color = reflectColor * fresnal + grabColor * (1 - fresnal);

      菲涅耳近似公式代码,详见:US3S8L6——菲涅尔反射

    5. 让主纹理的采样也受到 speed​ 影响,让水面贴图可以跟着水波动

      1
      2
      3
      // 把立方体反射纹理采样颜色叠加到物体主纹理采样颜色上,得到反射颜色
      fixed4 mainTex = tex2D(_MainTex, i.uv + speed); // 物体主纹理采样颜色,为其加上speed让水面贴图跟着水波动
      fixed4 reflectColor = texCUBE(_Cube, reflection) * mainTex;
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    fixed4 frag(v2f i): SV_TARGET
    {
    // 计算世界空间下视角方向
    float3 worldPos = float3(i.tangentToWorld0.w, i.tangentToWorld1.w, i.tangentToWorld2.w);
    fixed3 viewDir = normalize(UnityWorldSpaceViewDir(worldPos));

    // 加入一个水波移动速度的变量
    float2 speed = _Time.y * float2(_WaveXSpeed, _WaveYSpeed);
    // 通过让UV坐标沿相反方向移动,分别计算两个独立的动态法线扰动,这样可以模拟水波的波浪效果,即一个播放和一个波谷的反向运动
    fixed3 bump1 = UnpackNormal(tex2D(_BumpMap, i.uv.zw + speed)).rgb;
    fixed3 bump2 = UnpackNormal(tex2D(_BumpMap, i.uv.zw - speed)).rgb;
    // 将两个动态扰动法线叠加在一起,形成一个综合的扰动效果。这样会让法线扰动更加复杂且自然,避免单一扰动的生硬感
    fixed3 bump = normalize(bump1 + bump2);

    // 将切线空间下法线数据转换到世界空间下
    float3 worldNormal = float3(
    dot(i.tangentToWorld0.xyz, bump),
    dot(i.tangentToWorld1.xyz, bump),
    dot(i.tangentToWorld2.xyz, bump)
    );
    // 根据逆向视角方向和法线纹理内得到的法线纹理贴图计算反射向量
    float3 reflection = reflect(-viewDir, worldNormal);

    // 把立方体反射纹理采样颜色叠加到物体主纹理采样颜色上,得到反射颜色
    fixed4 mainTex = tex2D(_MainTex, i.uv + speed); // 物体主纹理采样颜色,为其加上speed让水面贴图跟着水波动
    fixed4 reflectColor = texCUBE(_Cube, reflection) * mainTex;
    // 折射颜色获取(即从物体遮挡的后边的屏幕内容采样,获取类似透明效果的颜色)
    float2 offset = bump.xy * _Distortion; // 在采样前计算xy屏幕坐标的偏移量
    i.grabPos.xy = offset * i.grabPos.z + i.grabPos.xy; // 用偏移量和屏幕空间深度值相乘,模拟出真实的折射效果
    float2 screenUV = i.grabPos.xy / i.grabPos.w; // 利用透视除法,将屏幕坐标转换到0~1范围内,然后再从屏幕纹理内采样
    fixed4 grabColor = tex2D(_GrabTexture, screenUV);
    // 根据schlick菲涅尔近似公式,计算菲涅尔反射率
    fixed fresnal = _FresnelScale + (1 - _FresnelScale) * pow((1 - dot(normalize(viewDir), normalize(worldNormal))), 5);
    // 通过折射程度,控制反射颜色和屏幕颜色的叠加,得到最终颜色
    fixed4 color = reflectColor * fresnal + grabColor * (1 - fresnal);
    return color;
    }

完整 Shader 如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
Shader "TeachShader/WaterWave"
{
Properties
{
_MainTex("MainTex", 2D) = ""{} // 主纹理
_BumpMap("BumpMap", 2D) = ""{} // 法线纹理
_Cube("Cubemap", Cube) = ""{} // 立方体纹理
_Distortion("Distortion", Range(0, 10)) = 0 // 控制扭曲程度的变量
_WaveXSpeed("WaveXSpeed", Range(-0.1, 0.1)) = 0.01 // 控制水波水平速度偏移的属性
_WaveYSpeed("WaveYSpeed", Range(-0.1, 0.1)) = 0.01 // 控制水波竖直速度偏移的属性
_FresnelScale("FresnelScale", Range(0, 1)) = 1 // 菲涅尔反射率
}
SubShader
{
Tags { "RenderType" = "Opaque" "Queue" = "Transparent" }

GrabPass {} // 捕获渲染此物体之前当前的屏幕内容,并存储到默认的渲染纹理变量内

Pass
{
Tags { "LightMode" = "ForwardBase" }

CGPROGRAM
#pragma vertex vert
#pragma fragment frag

#include "UnityCG.cginc"
#include "Lighting.cginc"

sampler2D _MainTex; // 颜色纹理
float4 _MainTex_ST; // 颜色纹理的缩放和平移
sampler2D _BumpMap; // 法线纹理
float4 _BumpMap_ST; // 法线纹理的缩放和平移
samplerCUBE _Cube; // 反射用的立方体纹理
sampler2D _GrabTexture; // GrabPass 默认存储的纹理变量
float _Distortion; // 扭曲程度
fixed _WaveXSpeed; // 控制水波水平速度偏移的属性
fixed _WaveYSpeed; // 控制水波竖直速度偏移的属性
float _FresnelScale; // 菲涅尔反射率

struct v2f
{
float4 pos: SV_POSITION; // 裁剪空间下的顶点坐标
float4 grabPos: TEXCOORD0; // 用于存储从屏幕图像中采样的坐标
float4 uv: TEXCOORD1; // 用于在颜色纹理(xy)和法线纹理(zw)中采样的UV坐标
float4 tangentToWorld0: TEXCOORD3; // 它用来存储变换矩阵和顶点相对于世界坐标的位置的第一行
float4 tangentToWorld1: TEXCOORD4; // 它用来存储变换矩阵和顶点相对于世界坐标的位置的第二行
float4 tangentToWorld2: TEXCOORD5; // 它用来存储变换矩阵和顶点相对于世界坐标的位置的第三行
};

v2f vert(appdata_full v)
{
v2f data;
data.pos = UnityObjectToClipPos(v.vertex); // 顶点坐标转裁剪坐标
data.grabPos = ComputeScreenPos(data.pos); // 将裁剪坐标转换到屏幕坐标
data.uv.xy = v.texcoord.xy * _MainTex_ST.xy + _MainTex_ST.zw; // 颜色纹理uv坐标计算
data.uv.zw = v.texcoord.xy * _BumpMap_ST.xy + _BumpMap_ST.zw; // 法线纹理uv坐标计算
float3 worldNormal = UnityObjectToWorldNormal(v.normal); // 顶点法线转世界坐标
fixed3 worldPos = mul(unity_ObjectToWorld, v.vertex).xyz; // 顶点坐标装世界坐标
// 将模型空间下的切线转换到世界空间下,并计算世界空间下的副切线
float3 worldTangent = UnityObjectToWorldDir(v.tangent);
float3 worldBinormal = cross(normalize(worldTangent), normalize(worldNormal)) * v.tangent.w;
// 将切线空间到世界空间的转换矩阵,以及世界坐标存储到三个贴图变量内
data.tangentToWorld0 = float4(worldTangent.x, worldBinormal.x, worldNormal.x, worldPos.x);
data.tangentToWorld1 = float4(worldTangent.y, worldBinormal.y, worldNormal.y, worldPos.y);
data.tangentToWorld2 = float4(worldTangent.z, worldBinormal.z, worldNormal.z, worldPos.z);

return data;
}

fixed4 frag(v2f i): SV_TARGET
{
// 计算世界空间下视角方向
float3 worldPos = float3(i.tangentToWorld0.w, i.tangentToWorld1.w, i.tangentToWorld2.w);
fixed3 viewDir = normalize(UnityWorldSpaceViewDir(worldPos));

// 加入一个水波移动速度的变量
float2 speed = _Time.y * float2(_WaveXSpeed, _WaveYSpeed);
// 通过让UV坐标沿相反方向移动,分别计算两个独立的动态法线扰动,这样可以模拟水波的波浪效果,即一个播放和一个波谷的反向运动
fixed3 bump1 = UnpackNormal(tex2D(_BumpMap, i.uv.zw + speed)).rgb;
fixed3 bump2 = UnpackNormal(tex2D(_BumpMap, i.uv.zw - speed)).rgb;
// 将两个动态扰动法线叠加在一起,形成一个综合的扰动效果。这样会让法线扰动更加复杂且自然,避免单一扰动的生硬感
fixed3 bump = normalize(bump1 + bump2);

// 将切线空间下法线数据转换到世界空间下
float3 worldNormal = float3(
dot(i.tangentToWorld0.xyz, bump),
dot(i.tangentToWorld1.xyz, bump),
dot(i.tangentToWorld2.xyz, bump)
);
// 根据逆向视角方向和法线纹理内得到的法线纹理贴图计算反射向量
float3 reflection = reflect(-viewDir, worldNormal);

// 把立方体反射纹理采样颜色叠加到物体主纹理采样颜色上,得到反射颜色
fixed4 mainTex = tex2D(_MainTex, i.uv + speed); // 物体主纹理采样颜色,为其加上speed让水面贴图跟着水波动
fixed4 reflectColor = texCUBE(_Cube, reflection) * mainTex;
// 折射颜色获取(即从物体遮挡的后边的屏幕内容采样,获取类似透明效果的颜色)
float2 offset = bump.xy * _Distortion; // 在采样前计算xy屏幕坐标的偏移量
i.grabPos.xy = offset * i.grabPos.z + i.grabPos.xy; // 用偏移量和屏幕空间深度值相乘,模拟出真实的折射效果
float2 screenUV = i.grabPos.xy / i.grabPos.w; // 利用透视除法,将屏幕坐标转换到0~1范围内,然后再从屏幕纹理内采样
fixed4 grabColor = tex2D(_GrabTexture, screenUV);
// 根据schlick菲涅尔近似公式,计算菲涅尔反射率
fixed fresnal = _FresnelScale + (1 - _FresnelScale) * pow((1 - dot(normalize(viewDir), normalize(worldNormal))), 5);
// 通过折射程度,控制反射颜色和屏幕颜色的叠加,得到最终颜色
fixed4 color = reflectColor * fresnal + grabColor * (1 - fresnal);
return color;
}
ENDCG
}
}
}

显示效果(对一个平面使用,扭动程度为5,XY轴移动速度为0.1,菲涅尔反射率为0.5):

image

可见,这时的水面呈现出了波浪扭动的效果,且垂直向下看水面更透明,向水平方向看水面反射率更高