1. 先搞清楚“水面Shader”到底要解决什么问题
如果你正在Unity、UE或者Three.js里做项目,需要一片能看的水面,那这篇文章就是为你准备的。网上很多水面Shader教程要么只讲一个波纹公式,要么直接丢给你一个看不懂的复杂工程。结果就是你复制粘贴后,要么效果假得像塑料布,要么性能卡得没法用,更别提让水面跟角色、子弹或者鼠标互动了。
所以,这篇文章不打算罗列所有数学公式,而是直接拆解一个可交互、有质感、性能可控的水面Shader从零到一的实现思路。核心就三件事:基础视觉(波纹、颜色)、交互逻辑(扰动、涟漪)、性能边界(复杂度、平台兼容)。无论你是想快速实现一个湖面,还是为游戏设计动态海域,都可以从这里找到可落地的步骤和参数调节逻辑。
我会假设你至少对ShaderLab或HLSL/GLSL有基础了解,知道什么是顶点/片元着色器。如果你完全没写过Shader,建议先了解下基础语法再回来看,会顺畅很多。
2. 环境与核心思路:别急着写代码,先想清楚管线
在动手写任何一行Shader代码之前,必须先明确你的渲染管线和目标平台。这是很多水面效果“移植就崩”的根源。
2.1 渲染管线决定你的工具链
- Unity内置管线/Built-in RP:最通用,资料最多。你可以用传统的Surface Shader(方便但黑盒)或者自己写Unlit Shader(更可控)。本文的技法主要基于可编程着色器思路,在内置管线里同样适用。
- Unity URP(Universal Render Pipeline):现在是Unity的主流推荐。它要求你使用Shader Graph(可视化)或者HLSL代码(URP Shader Library模板)。如果你的项目是URP,那么传统内置管线的Surface Shader写法大概率不兼容。本文的原理相通,但具体实现(如获取深度图、摄像机参数的方式)需要按URP的API来。
- Unity HDRP(High Definition RP):追求电影级画质,自带更复杂的水体系统。如果你用HDRP,通常不建议从头造轮子,而是基于其Lit Shader模板或Visual Effect Graph进行扩展。
- Three.js / WebGL:在GLSL中实现,核心原理一致,但需要注意WebGL 1.0/2.0的特性支持(如浮点纹理、Mipmap采样),以及如何通过JavaScript传递交互数据(如鼠标点击位置)。
我的建议是:如果是学习或快速原型,用Unity内置管线或URP+Shader Graph最容易出效果。如果是严肃的移动端项目,URP是更稳妥的选择,因为其对移动平台优化更好。
2.2 交互式水面的核心组件拆解
一个看起来“活”的水面,通常由以下几层效果叠加而成,我们需要逐一攻破:
- 几何变形(顶点动画):让平面“起伏”起来,产生波浪。这是性能消耗的大头。
- 法线与光照:根据变形后的表面计算法线,与灯光交互,产生明暗、高光(镜面反射),这是质感的关键。
- 颜色与透明度:模拟水的颜色、深度衰减(岸边浅、深处蓝)、透明度。
- 反射与折射:高级效果,可能需要渲染反射贴图(RenderTexture)或使用屏幕空间反射(SSR)。
- 交互扰动:响应外部力(如角色入水、子弹击中、鼠标点击),产生局部涟漪。
我们按从易到难的顺序,先实现一个有基础波浪和光照的静态水面,再加入交互式涟漪。
3. 实战第一步:构建基础波浪水面
我们从最简单的开始:一个会动的正弦波平面。
3.1 创建基础Shader与材质
在Unity中,创建一个Unlit Shader(或URP Unlit Shader),命名为“WaterBasic”。为其创建一个材质“Mat_WaterBasic”。
3.2 顶点着色器:实现正弦波变形
波浪的本质是让网格顶点的y轴(高度)随时间变化。我们使用最经典的正弦波叠加公式。
// 在顶点着色器函数中(例如 vert) v2f vert (appdata v) { v2f o; // 1. 计算世界空间下的波浪偏移 float waveFrequency = _WaveFrequency; // 波浪频率,控制波峰间距 float waveSpeed = _WaveSpeed; // 波浪速度 float waveHeight = _WaveHeight; // 波浪高度 // 使用顶点在世界XZ平面的位置和时间来采样波浪 float wave = sin((v.vertex.x * waveFrequency) + (_Time.y * waveSpeed)); wave += sin((v.vertex.z * waveFrequency * 1.3) + (_Time.y * waveSpeed * 1.7)) * 0.5; // 叠加第二层不同频率的波,让波形更自然 // 2. 应用高度偏移到顶点Y轴 v.vertex.y += wave * waveHeight; // 3. 计算变形后的法线(近似计算) // 这是一个简化版,更准确的法线需要基于偏导数,后面会讲 float derivative = cos((v.vertex.x * waveFrequency) + (_Time.y * waveSpeed)) * waveFrequency; float3 tangent = normalize(float3(1, derivative, 0)); float3 binormal = float3(0, 0, 1); // 简化假设 v.normal = cross(binormal, tangent); o.vertex = UnityObjectToClipPos(v.vertex); o.uv = TRANSFORM_TEX(v.uv, _MainTex); o.worldNormal = UnityObjectToWorldNormal(v.normal); o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz; return o; }在Properties块中声明对应的参数:
_WaveFrequency("Wave Freq", Range(0.1, 2)) = 0.5 _WaveSpeed("Wave Speed", Range(0, 5)) = 1.0 _WaveHeight("Wave Height", Range(0, 1)) = 0.1关键参数解读:
_WaveFrequency:值越大,波峰越密集,波浪看起来更“碎”。通常从0.5开始调节。_WaveSpeed:控制波浪动画的快慢。设为0则水面静止。_WaveHeight:波的振幅,直接决定起伏的剧烈程度。过大会导致模型穿帮。
此时运行,你应该能看到一个平面像布一样上下起伏了。但这还远远不够,它没有水的质感。
3.3 片元着色器:加入颜色与基础光照
水的颜色不是单一色,它通常由三部分组成:
- 底色:水体的基本颜色。
- 深度色:越深的地方颜色越深(通常偏蓝/绿)。
- 菲涅尔效应:视线与水面夹角越小(看远处水面),反射越强;夹角越大(看脚下水面),折射(透出底部)越强。
我们先实现一个简化的版本:
// 在片元着色器函数中(例如 frag) fixed4 frag (v2f i) : SV_Target { // 1. 基础颜色 fixed4 baseColor = _BaseColor; // 2. 简易深度模拟:用世界空间Y轴或视角深度做一个渐变 // 这里假设水面在Y=0,水下物体有深度 float depth = max(0, -i.worldPos.y); // 一个简单的模拟 fixed4 depthColor = lerp(_ShallowColor, _DeepColor, saturate(depth / _DepthMax)); // 3. 简易菲涅尔 float3 viewDir = normalize(_WorldSpaceCameraPos.xyz - i.worldPos.xyz); float fresnel = saturate(dot(viewDir, i.worldNormal)); fresnel = pow(1.0 - fresnel, _FresnelPower); // 菲涅尔系数 // 菲涅尔值大(看远处)时,混合更多“反射色”(这里用高光色模拟) fixed4 fresnelColor = lerp(baseColor, _SpecularColor, fresnel); // 4. 混合颜色 fixed4 finalColor = depthColor * fresnelColor; // 5. 加入简单的高光(Blinn-Phong简化版) float3 lightDir = normalize(_WorldSpaceLightPos0.xyz); float spec = pow(max(0, dot(normalize(viewDir + lightDir), i.worldNormal)), _Gloss); finalColor.rgb += _SpecularColor.rgb * spec * _SpecularIntensity; return finalColor; }同样,在Properties中补充:
_BaseColor("Base Color", Color) = (0.1, 0.3, 0.5, 1) _ShallowColor("Shallow Color", Color) = (0.2, 0.6, 0.8, 0.8) _DeepColor("Deep Color", Color) = (0.0, 0.1, 0.3, 1) _DepthMax("Depth Max", Range(0.1, 10)) = 5 _FresnelPower("Fresnel Power", Range(1, 10)) = 5 _SpecularColor("Specular Color", Color) = (1,1,1,1) _Gloss("Gloss", Range(8, 256)) = 64 _SpecularIntensity("Specular Intensity", Range(0, 2)) = 0.5现在你的水面应该有基本的颜色渐变和高光点了。但法线还是我们顶点着色器里简单计算的,很粗糙。
3.4 使用法线贴图:质感的飞跃
真实水面的细节(细碎波纹)靠顶点动画计算代价太高,标准做法是使用法线贴图(Normal Map)。我们采样一张水的法线贴图,并让其滚动,来模拟高频细节。
获取法线贴图:在Asset Store搜索“Water Normal Map”或使用类似下图样式的纹理。确保纹理导入设置中,
Texture Type设为Normal map。在Shader中采样并滚动:
// 在片元着色器中,计算法线贴图贡献的法线 float2 normalUV1 = i.uv + _Time.y * _NormalSpeed1; float2 normalUV2 = i.uv * _NormalScale2 + _Time.y * _NormalSpeed2 * float2(1.0, -0.8); // 第二层反向滚动,增加细节 fixed3 normalTex1 = UnpackNormal(tex2D(_NormalMap, normalUV1)); fixed3 normalTex2 = UnpackNormal(tex2D(_NormalMap, normalUV2)); fixed3 detailNormal = normalize(float3(normalTex1.xy + normalTex2.xy, normalTex1.z * normalTex2.z)); // 混合两层法线 // 将细节法线从切线空间转换到世界空间(需要传入切线TANGENT数据) float3 worldNormal = normalize(i.worldNormal); float3 worldTangent = normalize(i.worldTangent); float3 worldBitangent = normalize(cross(worldNormal, worldTangent)) * i.worldTangent.w; // 手性 float3x3 TBN = float3x3(worldTangent, worldBitangent, worldNormal); worldNormal = normalize(mul(detailNormal, TBN)); // 得到最终的世界空间法线用这个更精细的worldNormal去替换之前计算光照和菲涅尔时用的i.worldNormal,水面的光影瞬间会变得丰富、真实。
至此,一个视觉效果不错的静态水面就完成了。你可以调节波浪参数、颜色和法线贴图速度来匹配你的场景风格(平静池塘、湍急河流或海洋)。
4. 核心难点:实现交互式涟漪
静态水面再好,没有交互也缺乏生气。交互的核心是:让Shader知道“哪里”被“何时”“多大力”地触碰了,并在该点生成一个随时间扩散的波纹。
4.1 交互数据的传递:RenderTexture方案
CPU(游戏逻辑)和GPU(Shader)通信最灵活的方式之一,是使用一张RenderTexture作为“交互信息贴图”。这张贴图的每个像素可以记录水面某个对应位置的“扰动高度”或“扰动时间”。
实现步骤:
- 创建交互贴图:在脚本中创建一张RenderTexture(例如128x128),初始化为纯黑(0)。将它传递给Shader作为一个Texture2D属性(如
_InteractionMap)。 - 在脚本中绘制交互:当发生交互时(如鼠标点击、角色入水),在脚本中通过
Graphics.Blit或CommandBuffer,以一个“涟漪笔刷”纹理(一个从中心白向边缘黑衰减的圆形)去绘制(叠加)到这张_InteractionMap上。绘制的位置(UV)由交互的世界XZ坐标转换到水面的局部UV坐标得到。 - 在Shader中解码交互:在片元着色器中,采样
_InteractionMap。采样到的值(比如r通道)代表该点当前的“扰动强度”。用这个强度值去进一步偏移顶点高度或扰动法线。 - 衰减与清除:每一帧,在将
_InteractionMap传递给Shader前,先用一个衰减因子(如0.98)去乘整个贴图,让旧的涟漪逐渐消失。也可以定期完全清除。
这是一个简化的C#脚本示例(用于Unity):
public class WaterInteraction : MonoBehaviour { public RenderTexture interactionRT; // 拖拽赋值或代码创建 public Material waterMaterial; public Texture2D rippleBrush; // 涟漪笔刷纹理 public float rippleStrength = 1.0f; public float rippleDecay = 0.98f; private Material drawMaterial; // 用于绘制的临时材质 void Start() { // 初始化RenderTexture if (interactionRT == null) { interactionRT = new RenderTexture(128, 128, 0, RenderTextureFormat.RFloat); // 单精度浮点格式精度更高 interactionRT.wrapMode = TextureWrapMode.Clamp; interactionRT.Create(); } waterMaterial.SetTexture("_InteractionMap", interactionRT); drawMaterial = new Material(Shader.Find("Hidden/Internal-GUITextureClip")); // 使用一个简单的绘制Shader } void Update() { // 每帧衰减交互贴图 Graphics.Blit(interactionRT, interactionRT, drawMaterial, 0); // 通过一个*0.98的材质进行衰减Blit // 更优做法:使用Compute Shader进行高效的逐像素衰减。 // 检测交互(例如鼠标点击) if (Input.GetMouseButtonDown(0)) { Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition); RaycastHit hit; if (Physics.Raycast(ray, out hit) && hit.collider.gameObject == this.gameObject) { AddRippleAtPosition(hit.textureCoord); // hit.textureCoord是水面模型UV } } } void AddRippleAtPosition(Vector2 uv) { // 设置绘制材质参数 drawMaterial.SetTexture("_MainTex", rippleBrush); drawMaterial.SetFloat("_Strength", rippleStrength); drawMaterial.SetVector("_Center", new Vector4(uv.x, uv.y, 0, 0)); // 临时切换RenderTexture,绘制笔刷 RenderTexture.active = interactionRT; GL.PushMatrix(); GL.LoadPixelMatrix(0, 1, 0, 1); // 使用UV坐标空间 Graphics.DrawTexture(new Rect(uv.x - 0.05f, uv.y - 0.05f, 0.1f, 0.1f), rippleBrush, drawMaterial); GL.PopMatrix(); RenderTexture.active = null; } void OnDestroy() { if (interactionRT != null) interactionRT.Release(); } }4.2 在Shader中应用交互数据
在顶点或片元着色器中,采样_InteractionMap,并用其值影响顶点高度或法线。
// 在顶点着色器中 float interaction = tex2Dlod(_InteractionMap, float4(i.uv, 0, 0)).r; // 使用lod采样,避免在顶点着色器中使用tex2D // 将交互强度转换为高度偏移 float rippleHeight = interaction * _RippleHeightScale; v.vertex.y += rippleHeight; // 或者,更常见的是在片元着色器中影响法线,效果更柔和且性能更好 // 在片元着色器中 float interaction = tex2D(_InteractionMap, i.uv).r; // 计算交互带来的法线扰动 float2 gradient = float2(ddx(interaction), ddy(interaction)); // 求梯度,模拟涟漪的斜率 detailNormal.xy += gradient * _RippleNormalStrength; detailNormal = normalize(detailNormal);关键点:交互逻辑的复杂度与性能直接相关。高分辨率的_InteractionMap、每帧多次绘制、复杂的衰减计算都会带来开销。对于移动平台,务必简化。
5. 性能优化与平台兼容性排查
一个好看的水面Shader如果让帧率暴跌,就失去了意义。以下是关键的优化和排查点。
5.1 性能开销大头分析
- 顶点动画:每个顶点每帧都要做正弦波计算。对策:控制水面网格的面数。对于大水面,使用LOD(Level of Detail),远处用低模+简单Shader,近处用高模+完整效果。
- 法线贴图采样:特别是多张法线贴图混合和滚动。对策:将多层法线贴图合并到一张RGBA贴图中(如R通道存第一层,G通道存第二层),减少采样次数。在移动端,考虑只使用一层。
- RenderTexture交互:每帧对RT进行读写和衰减。对策:降低RT分辨率(64x64有时也够用)。使用
ComputeShader进行高效的并行衰减和叠加计算,比Graphics.Blit更快。 - 复杂光照计算:每像素多次的
dot、pow、normalize操作。对策:在URP中,利用其内置的光照函数。在Built-in管线中,考虑使用简化的光照模型(如半兰伯特)。 - 反射/折射:实时渲染反射贴图(将场景渲染到纹理)是性能杀手。对策:使用平面反射(Planar Reflection)技术,但只反射近处重要物体;或者使用屏幕空间反射(SSR),但注意硬件支持度和瑕疵。对于风格化或移动项目,经常用一张**天空盒立方体贴图(Cubemap)**模拟反射,性价比最高。
5.2 常见平台问题排查清单
- OpenGL ES 3.0 / WebGL 1.0 兼容性:
- 问题:
ddx/ddy(偏导数)函数可能不被支持或行为不一致,这会影响法线贴图、边缘光、交互梯度计算。 - 排查:检查Shader编译日志。如果报错,考虑使用预计算的法线贴图,或禁用依赖
ddx/ddy的效果。
- 问题:
- RenderTexture格式:
- 问题:移动设备上
RenderTextureFormat.RFloat可能不支持。 - 排查:使用
SystemInfo.SupportsRenderTextureFormat进行检查。回退方案是使用ARGBHalf或ARGB32格式,但精度会降低。
- 问题:移动设备上
- Adreno GPU(部分高通芯片)特定问题:
- 问题:在顶点着色器中采样纹理(
tex2Dlod)可能有性能问题或精度问题。 - 排查:尽量将交互数据通过顶点数据(如颜色或第二套UV)传递,或在片元着色器中采样后通过插值器传到顶点阶段(这需要额外Pass,较复杂)。最简单的方案是降低交互精度要求。
- 问题:在顶点着色器中采样纹理(
- Overdraw(过度绘制):
- 问题:半透明水面会覆盖大量像素,且可能多层叠加。
- 排查:使用Unity的Frame Debugger或Overdraw视图查看。确保水面材质合理排序(在所有不透明物体之后,在半透明物体之前),并尽量减少水面覆盖的屏幕面积(用雾、边界遮挡)。
5.3 参数调节经验
- 波浪(
_WaveHeight,_WaveFrequency):大而缓的波浪(低频率,中高度)适合海洋;小而密的波纹(高频率,低高度)适合池塘或雨后积水。永远不要为了“明显”而把高度调得过高,否则会穿帮。 - 法线贴图速度(
_NormalSpeed):滚动太快会显得不自然,像快速流动的油。慢速滚动(0.01~0.1量级)更适合开阔水面。 - 菲涅尔(
_FresnelPower):这个值控制反射和折射的过渡锐利程度。值越大,过渡越尖锐(远处一小片很亮,脚下突然变透明)。对于平静水面,可以调高(5-8);对于有波浪的水面,调低(2-4)可以让反射更分散。 - 交互强度(
_RippleHeightScale,_RippleNormalStrength):交互效果宁弱勿强。一个轻微的涟漪法线扰动,配合适当的高光变化,比剧烈的顶点起伏看起来更真实、性能更好。
6. 从Demo到生产:需要加固的环节
把Shader拖到材质球上能跑通,只完成了10%。要放到真实项目中,还需要考虑:
- 与场景的融合:
- 岸边衔接:使用一张深度图或根据场景网格生成距离场,在水面Shader中让浅水区透明度增加、颜色变浅,并混合岸边的沙子/岩石纹理。
- 水下效果:如果需要看到水下,需要处理折射。简易版可以用GrabPass抓取屏幕背景进行扭曲;高级版需要渲染水下场景。
- 动态天气与时间:将光照颜色、强度、天空盒反射强度等参数与你的游戏时间/天气系统联动。阴天的水面高光弱、反射暗。
- 多水质支持:通过Shader变体或参数控制,快速切换成“泥水”、“岩浆”、“毒液”等效果。主要是调整颜色、粘度(波浪和涟漪的衰减速度)、泡沫贴图等。
- 批量渲染与GPU Instancing:如果你的场景有大量相同材质的水面(如多个小水坑),确保Shader支持GPU Instancing,可以大幅降低Draw Call。
- Shader变体管理:一个功能丰富的水面Shader会有很多
#ifdef分支(如_USE_INTERACTION_ON,_USE_REFRACTION_ON)。使用shader_feature并在材质面板上明确开关,避免产生不需要的Shader变体,导致包体过大和编译时间过长。
最后也是最关键的一点:不要追求一次性实现所有效果。先从最基本的波浪+法线贴图+颜色开始,确保它在目标平台(尤其是移动端)上稳定运行在60帧。然后,像搭积木一样,一个一个地加入交互、反射、深度融合等高级特性,每加一个都要测试性能和效果。最酷的技术,如果让游戏卡成幻灯片,也失去了意义。