如何用Shader实现逼真水面?

12 人参与

说起我第一次手敲水面Shader的经历,真是一次意外的惊喜。那天我在Unity里打开一个小湖泊的场景,默认的平面水面看起来像是被抹了层蓝漆,根本没有那种波光粼粼的感觉。于是我决定自己写一段Shader,想把真实的波浪、光的折射和水底的光斑都搬进来。结果一行行代码跑出来的效果,直接把我惊呆了——那种微光在波峰上跳舞的感觉,简直太好用了。

波浪的数学——Gerstner波

要让水面起伏像真的海浪,单纯的顶点位移根本不够。Gerstner波是一种基于正弦函数的波形,它可以让每个顶点沿着水流方向产生横向和纵向的位移,甚至还能模拟波峰的倾斜。核心公式其实不复杂:position += amplitude * sin(k·x – ω·t),再加上一个方向向量的乘积,就能得到横向的漂移。把几组不同频率、不同方向的Gerstner波叠加在一起,就能得到既有大浪又有细浪的层次感。

法线扰动与噪声

波浪只负责几何形状,光照还得靠法线来决定反射和折射的强度。这里我常用两张噪声纹理:一张是低频的海浪噪声,用来做大范围的法线扰动;另一张是高频的细浪噪声,随时间平移后混合进去,让水面看起来更细腻。把噪声的RGB值映射成法线方向,再和Gerstner波算出来的几何法线混合,效果立刻提升一个档次。

Shader "Custom/RealisticWater"
{
    Properties{
        _MainTex ("Base Color", 2D) = "white" {}
        _NormalLow ("LowFreq Normal", 2D) = "bump" {}
        _NormalHigh ("HighFreq Normal", 2D) = "bump" {}
        _WaveScale ("Wave Scale", Range(0,2)) = 1
        _FresnelPower ("Fresnel Power", Range(1,5)) = 2
        _TimeScale ("Time Scale", Float) = 1
    }
    SubShader{
        Tags{ "RenderType"="Transparent" }
        LOD 200
        Pass{
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #include "UnityCG.cginc"

            struct appdata{
                float4 vertex : POSITION;
                float2 uv : TEXCOORD0;
            };
            struct v2f{
                float4 pos : SV_POSITION;
                float2 uv : TEXCOORD0;
                float3 worldPos : TEXCOORD1;
                float3 normal : TEXCOORD2;
            };

            sampler2D _MainTex, _NormalLow, _NormalHigh;
            float _WaveScale, _FresnelPower, _TimeScale;

            float3 GerstnerWave(float3 pos, float2 dir, float amp, float freq, float phase){
                float k = freq * 2 * 3.14159;
                float c = sqrt(9.8 / k);
                float theta = k * dot(dir, pos.xz) - c * _Time.y * _TimeScale + phase;
                float disp = amp * sin(theta);
                float3 offset = float3(dir.x, 0, dir.y) * disp;
                offset.y = amp * cos(theta);
                return offset;
            }

            v2f vert(appdata v){
                v2f o;
                float3 worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;
                // 三组不同方向的Gerstner波
                worldPos += GerstnerWave(worldPos, float2(1,0), 0.1, 0.4, 0);
                worldPos += GerstnerWave(worldPos, float2(0.5,0.8), 0.05, 1.2, 1);
                worldPos += GerstnerWave(worldPos, float2(-0.8,0.3), 0.02, 2.5, 2);
                o.worldPos = worldPos;
                o.pos = UnityObjectToClipPos(float4(worldPos,1));
                o.uv = v.uv;
                // 采样噪声法线并混合
                float3 nLow = UnpackNormal(tex2D(_NormalLow, v.uv * _WaveScale + _Time.y * 0.05));
                float3 nHigh = UnpackNormal(tex2D(_NormalHigh, v.uv * _WaveScale * 2 + _Time.y * 0.2));
                o.normal = normalize(nLow * 0.7 + nHigh * 0.3);
                return o;
            }

            fixed4 frag(v2f i) : SV_Target{
                float3 viewDir = normalize(_WorldSpaceCameraPos - i.worldPos);
                float fresnel = pow(1 - dot(viewDir, i.normal), _FresnelPower);
                fixed4 col = tex2D(_MainTex, i.uv);
                col.rgb = lerp(col.rgb, float3(0.8,0.9,1), fresnel);
                return col;
            }
            ENDCG
        }
    }
}

光照与菲涅尔

水面最显眼的光学现象莫过于视角变化带来的反射强度。菲涅尔公式可以用一行pow(1 - dot(viewDir, normal), power)来近似实现——从正上方看几乎全是折射,侧面观看则镜面反射占主导。把这段计算和环境光、天空盒的采样混合,就能得到自然的高光跳动。再配上一个简单的折射UV偏移,水底的纹理会随波浪微微晃动,逼真感立马提升。

底部光斑——Caustics

如果你想让水底出现光斑,那就得把光线穿过波浪后投射的效果算进去。最省事的办法是准备一张高频噪声贴图,在片元着色器里根据法线的扰动把它做一次坐标扭曲,再乘以一个随时间变化的强度系数。这样得到的光斑会随波浪呼吸,尤其在阳光直射的场景里,效果简直让人忍不住想把相机对准水面。

写完这些代码后,我把场景跑到60帧以上,CPU几乎没感到压力。最让人开心的不是帧数,而是那种站在湖边看到水面波光粼粼、光斑在水底舞动的真实感——这正是Shader带来的魔法。下次你在海边看到浪花翻滚,别忘了背后也可能有一段小小的代码在悄悄工作。

参与讨论

12 条评论
  • 黑夜的耳语

    这波光效果真的很赞

  • HushOfMidnight

    Gerstner波公式记住了

  • 宝石Ruby

    噪声法线混合挺妙的

  • 笔底风

    光斑随波动看得我发呆

  • 脸滚键盘

    帧数保持在60真不易

  • 蓝牙已断开

    有没有人试过在移动平台上跑这个Shader,性能会不会掉帧?

  • 梦蝶游踪

    我之前在VR项目里用了类似的噪声法线,调高频后会有噪点,需要再滤波。

  • 竹叶青幽

    低频噪声和高频噪声的比例我一般设0.6/0.4,你们怎么调的?

  • 白龙卫

    光照里菲涅尔的参数调大点,侧面看起来更有金属感,真的很明显。

  • 寂静的森林

    这个Gerstner波叠加三组方向的写法挺直观,复制粘贴就能用。

  • 豆粒软弹

    实测下来,加入Caustics后在强光下水底光斑会随波浪起伏,画面感提升不少,不过要注意贴图分辨率,否则会出现噪点。

  • 梦中游

    如果想进一步提升真实感,可以在片元里再加一点屏幕空间反射(SSR),虽然开销不小,但在中等配置下还能保持60帧,值得一试😊