说起我第一次手敲水面Shader的经历,真是一次意外的惊喜。那天我在Unity里打开一个小湖泊的场景,默认的平面水面看起来像是被抹了层蓝漆,根本没有那种波光粼粼的感觉。于是我决定自己写一段Shader,想把真实的波浪、光的折射和水底的光斑都搬进来。结果一行行代码跑出来的效果,直接把我惊呆了——那种微光在波峰上跳舞的感觉,简直太好用了。
要让水面起伏像真的海浪,单纯的顶点位移根本不够。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偏移,水底的纹理会随波浪微微晃动,逼真感立马提升。
如果你想让水底出现光斑,那就得把光线穿过波浪后投射的效果算进去。最省事的办法是准备一张高频噪声贴图,在片元着色器里根据法线的扰动把它做一次坐标扭曲,再乘以一个随时间变化的强度系数。这样得到的光斑会随波浪呼吸,尤其在阳光直射的场景里,效果简直让人忍不住想把相机对准水面。
写完这些代码后,我把场景跑到60帧以上,CPU几乎没感到压力。最让人开心的不是帧数,而是那种站在湖边看到水面波光粼粼、光斑在水底舞动的真实感——这正是Shader带来的魔法。下次你在海边看到浪花翻滚,别忘了背后也可能有一段小小的代码在悄悄工作。
参与讨论
这波光效果真的很赞
Gerstner波公式记住了
噪声法线混合挺妙的
光斑随波动看得我发呆
帧数保持在60真不易
有没有人试过在移动平台上跑这个Shader,性能会不会掉帧?
我之前在VR项目里用了类似的噪声法线,调高频后会有噪点,需要再滤波。
低频噪声和高频噪声的比例我一般设0.6/0.4,你们怎么调的?
光照里菲涅尔的参数调大点,侧面看起来更有金属感,真的很明显。
这个Gerstner波叠加三组方向的写法挺直观,复制粘贴就能用。
实测下来,加入Caustics后在强光下水底光斑会随波浪起伏,画面感提升不少,不过要注意贴图分辨率,否则会出现噪点。
如果想进一步提升真实感,可以在片元里再加一点屏幕空间反射(SSR),虽然开销不小,但在中等配置下还能保持60帧,值得一试😊