
1. 项目概述与核心价值最近在社区里看到不少朋友对Unity Shader编程既向往又犯怵觉得它神秘莫测是图形程序员的专属领域。其实不然Shader的本质是一段运行在GPU上的小程序它定义了物体从三维空间到最终屏幕像素的每一个渲染步骤。今天我们就从一个极具视觉表现力且原理清晰的案例入手——打造一个自定义的实时水波纹效果。这个项目不依赖任何复杂的第三方插件纯粹从零开始用代码“画”出流动的波纹。无论你是想为你的独立游戏增添一抹动态的湖光还是仅仅想揭开Shader那层神秘的面纱这篇手把手的实战指南都将带你走完全程。你会亲身体验到从定义顶点的起伏到计算每一个片元像素的颜色与亮度最终让一片平淡的平面“活”过来的完整过程。为什么选择水波纹作为第一个实战项目首先它的视觉效果反馈非常直接和令人满足一点点代码改动就能立刻在场景中看到变化这对于学习动力是极大的鼓舞。其次它几乎涵盖了Shader编程最核心的几个概念顶点着色器对网格形状的操控、片元着色器对颜色的精细计算、时间变量的引入实现动画以及对正弦函数的运用来模拟周期性波动。理解了这个案例你就掌握了编写动态效果Shader的通用思维模式。在开始之前请确保你有一个基本的Unity开发环境建议使用较新版本如2021 LTS或2022 LTS我们将使用Unity内置的ShaderLab语言和HLSL/Cg来编写代码。2. 核心原理与设计思路拆解在动手写代码之前我们必须先搞清楚一个核心问题静态的平面网格如何通过Shader变成动态的水面答案的关键在于顶点位移和法线扰动。2.1 从顶点位移到视觉波纹我们的水面最初只是一个由众多三角形构成的平面网格一个Plane。每个三角形由三个顶点Vertex定义。在常规渲染中这些顶点的位置是固定的。要产生波纹我们就要在Shader的顶点着色器Vertex Shader阶段动态地修改这些顶点的Y轴高度坐标。如何修改我们使用正弦波Sine Wave函数。想象一下向平静的水面投入一颗石子涟漪会以撞击点为中心向外扩散。这种扩散的波形近似于正弦波。在Shader中我们可以根据顶点在世界空间中的X和Z坐标计算出一个随时间变化的正弦值然后将这个值加到顶点原本的Y坐标上。公式的核心思想是新Y坐标 原Y坐标 振幅 * sin(频率 * 距离 时间 * 速度)。这里的“距离”可以是顶点到某个中心点的距离也可以是直接使用顶点的X或Z坐标来产生方向性的波纹。通过调整振幅波的高度、频率波的密集程度和速度波动的快慢我们就能控制波纹的形态。2.2. 法线的重要性光影与质感仅仅让顶点上下运动你只会得到一个凹凸不平、但看起来像塑料或石膏的怪异平面缺乏水的光泽和透明感。这是因为光照计算依赖于法线Normal。法线是垂直于模型表面每个点的向量它决定了光线如何反射从而决定了模型的明暗和质感。当我们的顶点发生位移后模型表面的实际朝向已经改变了但顶点自带的原始法线指向上方并没有变。这就导致了光照错误物体看起来不真实。因此在顶点着色器中修改了位置后我们必须根据新的表面形状重新计算法线向量。这个计算通常通过获取位移后相邻顶点的位置差进行叉积运算来得到。正确的法线是让水面产生波光粼粼的高光反射效果的关键。2.3. 片元着色器的润色颜色与反射顶点着色器“塑造”了水的形状而片元着色器Fragment/Pixel Shader则负责“绘制”水的颜色和细节。在这里我们可以做很多增强视觉效果的事情基于深度的颜色渐变模拟真实水体中心深的地方颜色偏蓝、偏暗边缘浅的地方颜色偏绿、偏透明。我们可以根据顶点在模型空间或世界空间中的位置来混合颜色。菲涅尔效应Fresnel Effect这是一个非常重要的光学现象视线与水面夹角越小掠射角反射越强视线垂直看向水面则更容易看到水下的内容折射。我们可以用视角方向与法线的点积来模拟这一点让水面的边缘反射更强烈。镜面反射高光根据光照方向、视角方向和法线计算一个镜面高光项例如使用Blinn-Phong模型让水面在特定角度有闪亮的光斑。纹理扰动为了增加细节我们可以使用一张噪声或波纹纹理对水面颜色或法线进行微弱的扰动模拟微风拂过或细小涟漪的效果。我们的首个项目将聚焦于最核心的顶点动画和基础光照实现一个动态的、有光影变化的波纹平面。更高级的菲涅尔、折射等效果可以作为后续的扩展练习。3. 环境准备与Shader框架搭建3.1 创建基础场景与材质打开Unity创建一个新项目或场景。在场景中创建一个3D Plane对象这将作为我们的“水面”。你可以适当放大它例如Scale设置为 (10, 1, 10)。接着创建一个方向光Directional Light调整角度以产生合适的光影。接下来是关键步骤创建Shader和Material。在Project窗口中右键选择Create - Shader - Unlit Shader。先从一个最简单的无光照Shader模板开始这样我们可以更清晰地看到顶点位移的效果不受复杂光照模型干扰。将其命名为“WaterRipple_Unlit”。再次右键选择Create - Material命名为“Mat_Water”。创建好后将它的Shader属性下拉选择我们刚刚创建的“WaterRipple_Unlit”。最后将“Mat_Water”材质拖拽赋值给场景中的Plane对象。现在Plane应该变成了洋红色Unlit Shader的默认颜色。这说明我们的材质和Shader已经成功关联。3.2 解析Unlit Shader模板代码双击打开“WaterRipple_Unlit.shader”文件。你会看到Unity生成的模板代码。我们来快速理解一下关键部分Shader Unlit/WaterRipple_Unlit { Properties { // 属性块这里定义的变量会显示在Material的Inspector面板上 _MainTex (Texture, 2D) white {} } SubShader { Tags { RenderTypeOpaque } // 渲染标签这里是不透明 LOD 100 Pass { CGPROGRAM #pragma vertex vert // 指定顶点着色器函数名为 vert #pragma fragment frag // 指定片元着色器函数名为 frag #include UnityCG.cginc // 包含Unity的内置函数和结构体 // 从Properties块传递进来的变量需要在CGPROGRAM中再次声明 sampler2D _MainTex; float4 _MainTex_ST; // 应用程序传入顶点着色器的数据结构 struct appdata { float4 vertex : POSITION; // 顶点位置模型空间 float2 uv : TEXCOORD0; // 第一套纹理坐标 }; // 顶点着色器输出到片元着色器的数据结构 struct v2f { float2 uv : TEXCOORD0; float4 vertex : SV_POSITION; // 裁剪空间中的顶点位置 }; // 顶点着色器函数 v2f vert (appdata v) { v2f o; o.vertex UnityObjectToClipPos(v.vertex); // 将顶点从模型空间转换到裁剪空间 o.uv TRANSFORM_TEX(v.uv, _MainTex); // 处理纹理缩放和偏移 return o; } // 片元着色器函数 fixed4 frag (v2f i) : SV_Target { fixed4 col tex2D(_MainTex, i.uv); // 采样纹理颜色 return col; } ENDCG } } }这是我们改造的基石。目前这个Shader只是简单地把纹理贴到模型上没有任何顶点动画。注意在编写Shader时一个常见的错误是混淆了不同的坐标空间模型空间、世界空间、裁剪空间等。UnityObjectToClipPos这个内置函数帮我们完成了从模型空间到裁剪空间的复杂矩阵变换在初学时强烈建议依赖这些内置函数避免自己处理矩阵乘法。4. 实现核心顶点动画让水面波动起来现在我们要在顶点着色器vert函数中注入灵魂——正弦波位移。4.1 定义可调节的波纹属性首先在Properties块中添加我们控制波纹的参数Properties { _MainTex (Texture, 2D) white {} _Amplitude (振幅 (Amplitude), Range(0, 1)) 0.1 _Frequency (频率 (Frequency), Range(0, 10)) 1 _Speed (波速 (Speed), Range(0, 5)) 1 }这些属性会以滑动条的形式出现在材质面板上方便我们实时调节。在CGPROGRAM中同样需要声明这些变量float _Amplitude; float _Frequency; float _Speed;4.2 修改顶点着色器计算位移我们将重写vert函数。思路是获取顶点在模型空间中的原始位置。计算一个基于XZ坐标和时间的正弦值。将这个值乘以振幅加到原始位置的Y坐标上。将位移后的新顶点坐标转换到裁剪空间。修改后的vert函数如下v2f vert (appdata v) { v2f o; // 1. 在模型空间计算位移 float4 modifiedVertex v.vertex; // 获取原始顶点 // 计算正弦波使用X和Z坐标共同影响产生圆形扩散感。_Time.y是自场景加载以来的总时间秒。 float wave sin((modifiedVertex.x * _Frequency) (_Time.y * _Speed)); wave sin((modifiedVertex.z * _Frequency) (_Time.y * _Speed)); // 叠加另一个方向效果更自然 wave * 0.5; // 将叠加后的值范围大致规整到[-1, 1] // 2. 应用位移到Y轴 modifiedVertex.y wave * _Amplitude; // 3. 将位移后的顶点转换到裁剪空间 o.vertex UnityObjectToClipPos(modifiedVertex); o.uv TRANSFORM_TEX(v.uv, _MainTex); return o; }保存Shader后回到Unity编辑器。稍等片刻编译后你应该能看到场景中的平面开始像一块抖动的果冻一样上下运动了通过调整材质面板上的_Amplitude、_Frequency和_Speed参数可以观察到波纹高度、密度和速度的变化。4.3 从“果冻”到“涟漪”优化波纹形态目前的波纹看起来更像是一个整体上下震荡的果冻而不是从中心扩散的涟漪。为了模拟涟漪我们需要让波纹的位移与顶点到中心的距离相关。修改vert函数中的位移计算部分v2f vert (appdata v) { v2f o; float4 modifiedVertex v.vertex; // 计算顶点到模型中心的距离假设Plane中心在(0,0,0) float distanceFromCenter length(float3(modifiedVertex.x, 0, modifiedVertex.z)); // 使用距离作为正弦函数的输入产生环形波 float wave sin((distanceFromCenter * _Frequency) (_Time.y * _Speed)); modifiedVertex.y wave * _Amplitude; o.vertex UnityObjectToClipPos(modifiedVertex); o.uv TRANSFORM_TEX(v.uv, _MainTex); return o; }现在波纹应该呈现出从中心向外扩散的环形效果了。你可以尝试不同的函数组合比如使用_SinTime一个随时间循环的正弦值向量来获得更柔和的变化。实操心得在顶点着色器中进行复杂的每顶点计算如多次三角函数运算会对性能产生影响尤其是顶点数很多的模型。对于水面这种大平面适当的网格细分是必要的但也要避免过度细分。通常一个200x200顶点的平面对于中距离水面已经足够。性能敏感的项目中可以考虑将部分计算转移到片元着色器或者使用顶点纹理动画等更高效的技术。5. 引入光照模型计算动态法线为了让水面看起来有光泽我们需要引入光照。我们将把Shader从“Unlit”升级为支持标准光照的“Surface Shader”风格但为了理解本质我们依然在顶点-片元着色器架构中手动实现一个简单的漫反射光照。5.1 修改数据结构与传递法线首先需要让顶点着色器接收法线信息并计算新的法线后传递给片元着色器。修改appdata结构体加入法线struct appdata { float4 vertex : POSITION; float3 normal : NORMAL; // 添加法线语义 float2 uv : TEXCOORD0; };修改v2f结构体增加一个变量用于存储世界空间法线struct v2f { float2 uv : TEXCOORD0; float3 worldNormal : TEXCOORD1; // 用于传递世界空间法线 float4 vertex : SV_POSITION; };5.2 在顶点着色器中计算新法线这是最关键且稍有难度的一步。在顶点着色器中位移顶点后我们不能直接使用原始法线。一个常用且高效的近似方法是利用正弦函数的导数余弦来估算位移后表面的斜率从而构造新的法线。对于我们的位移函数y A * sin(F * x T)其对x的偏导数是A * F * cos(F * x T)。这个导数代表了表面在X方向上的倾斜度。同理可以求出Z方向的偏导。然后我们可以用(-dx/dx, 1, -dx/dz)这样的向量来近似新的法线归一化前。在vert函数中位移计算后添加v2f vert (appdata v) { v2f o; float4 modifiedVertex v.vertex; float distanceFromCenter length(float3(modifiedVertex.x, 0, modifiedVertex.z)); // 为了计算法线需要先算出波函数和它的导数 float phase (distanceFromCenter * _Frequency) (_Time.y * _Speed); float wave sin(phase); float waveDerivative _Frequency * cos(phase); // 这是对distanceFromCenter求导的结果 modifiedVertex.y wave * _Amplitude; // 计算新的法线近似 // 归一化距离方向向量 (x, 0, z) / distance float3 dirToCenter normalize(float3(modifiedVertex.x, 0, modifiedVertex.z)); // 斜率主要发生在距离方向上。新的法线近似为(-斜率在xz平面上的投影, 1) // 由于是径向波斜率方向就是dirToCenter方向 float3 slope dirToCenter * (waveDerivative * _Amplitude); float3 newNormal normalize(float3(-slope.x, 1.0, -slope.z)); o.worldNormal UnityObjectToWorldNormal(newNormal); // 将法线从模型空间转换到世界空间 o.vertex UnityObjectToClipPos(modifiedVertex); o.uv TRANSFORM_TEX(v.uv, _MainTex); return o; }这段代码是近似计算对于环形波是可行的。更通用的方法是在着色器中采样高度图或通过相邻顶点位置差来精确计算但复杂度更高。对于我们的学习目的这个近似方法已经能产生很好的视觉效果。5.3 在片元着色器中实现漫反射光照现在我们在片元着色器中使用这个新的世界空间法线来计算简单的兰伯特Lambert漫反射光照。首先我们需要光的方向。Unity内置变量_WorldSpaceLightPos0对于平行光来说其xyz分量就是光的方向世界空间。修改frag函数fixed4 frag (v2f i) : SV_Target { // 采样纹理颜色作为基础色 fixed4 col tex2D(_MainTex, i.uv); // 兰伯特漫反射光照计算 float3 lightDir normalize(_WorldSpaceLightPos0.xyz); // 获取平行光方向 float3 normal normalize(i.worldNormal); // 确保法线是单位向量 float lambert max(0, dot(normal, lightDir)); // 点积结果在[0,1]之间 // 将光照影响应用到颜色上。_LightColor0是平行光的颜色 col.rgb * lambert * _LightColor0.rgb; // 添加一点点环境光避免完全黑暗 col.rgb UNITY_LIGHTMODEL_AMBIENT.rgb * col.rgb; return col; }保存并返回Unity。现在你的水面应该有了明暗变化朝着光源的方向更亮背光处更暗并且随着波纹的波动光影也会动态变化水面的质感立刻提升了几个档次注意事项这里我们直接使用了_WorldSpaceLightPos0和_LightColor0这在只有一个平行光且场景中使用了Unity的Forward Rendering前向渲染路径时是有效的。对于更复杂的多光源或延迟渲染路径需要更复杂的处理方式比如使用Surface Shader或自己编写多Pass光照。作为入门我们暂时用这个简单模型。6. 增强视觉效果添加颜色渐变与高光有了基础的光影我们可以进一步美化水面。6.1 基于“深度”的颜色渐变在Properties中添加两个颜色属性用于表示浅水区和深水区的颜色_ShallowColor (浅水区颜色, Color) (0.2, 0.6, 0.9, 1) _DeepColor (深水区颜色, Color) (0.0, 0.2, 0.4, 1)在CGPROGRAM中声明对应的变量。然后在片元着色器中我们可以根据顶点到中心的距离模拟深度或根据法线指向朝上的面更浅来混合这两个颜色。这里我们使用一个简单的基于原始顶点Y坐标位移前或法线Y分量的混合因子。修改v2f结构体将顶点在模型空间的位置位移前或计算好的深度因子传递到片元着色器。为了简单我们直接在顶点着色器中计算一个深度因子// 在v2f结构体中添加 float depthFactor : TEXCOORD2; // 在vert函数末尾计算并赋值 // 假设平面中心是原点边缘深度因子为0中心为1。使用顶点到中心的距离用1-saturate()反转。 o.depthFactor 1.0 - saturate(distanceFromCenter / 5.0); // 5.0是一个可调的缩放系数在片元着色器中fixed4 frag (v2f i) : SV_Target { // 根据深度因子混合颜色 fixed4 waterColor lerp(_DeepColor, _ShallowColor, i.depthFactor); // ... 光照计算使用waterColor作为基础色... waterColor.rgb * lambert * _LightColor0.rgb; waterColor.rgb UNITY_LIGHTMODEL_AMBIENT.rgb * waterColor.rgb; return waterColor; }6.2 添加镜面反射高光Blinn-Phong高光能让水面在特定角度看起来闪闪发光。我们需要视角方向。Unity提供了_WorldSpaceCameraPos变量表示相机世界坐标我们可以用片元的世界坐标需要从顶点着色器传递来计算视角方向。首先修改v2f结构体传递世界空间顶点位置struct v2f { float2 uv : TEXCOORD0; float3 worldNormal : TEXCOORD1; float3 worldPos : TEXCOORD2; // 新增世界空间位置 float depthFactor : TEXCOORD3; float4 vertex : SV_POSITION; };在vert函数中计算并赋值o.worldPos mul(unity_ObjectToWorld, modifiedVertex).xyz; // 将位移后的顶点转换到世界空间在frag函数中计算高光fixed4 frag (v2f i) : SV_Target { // ... 颜色混合和漫反射计算 ... // 计算视角方向和半角向量Blinn-Phong模型 float3 viewDir normalize(_WorldSpaceCameraPos.xyz - i.worldPos); float3 halfDir normalize(lightDir viewDir); // 计算高光强度 float specular pow(max(0, dot(normal, halfDir)), _Gloss); // _Gloss是高光指数可在Properties定义 specular * _SpecularStrength; // _SpecularStrength是高光强度 // 将高光加到最终颜色上通常高光是加色与漫反射颜色相加 waterColor.rgb _LightColor0.rgb * specular; return waterColor; }通过调整_Gloss控制高光点大小和_SpecularStrength控制高光亮度你可以获得从湿润的塑料感到波光粼粼的金属感等各种不同的水面质感。7. 常见问题、优化与扩展思路7.1 常见问题排查水面没有动静/Shader编译错误检查确保Shader文件已保存并且没有语法错误。在Unity编辑器的Console窗口中查看是否有编译错误红色消息。检查材质球是否正确地引用了你的Shader。检查_Time变量是否被正确使用。确保在CGPROGRAM中声明了uniform float4 _Time;虽然通常Unity会自动包含。波纹看起来不自然或呈条带状原因可能是正弦波频率过高或网格细分不够。尝试降低_Frequency值。解决增加Plane的网格分段数在创建Plane时或在3D建模软件中创建更高精度的平面网格再导入。优化可以尝试使用多个不同频率和速度的正弦波叠加模拟更复杂的海面。例如wave sin(...)*0.5 sin(...*1.7)*0.3。光照效果奇怪部分区域全黑或全亮检查法线计算是否正确。确保在片元着色器中对i.worldNormal进行了normalize操作。检查光的方向_WorldSpaceLightPos0是否正确。在场景中调整平行光的角度观察变化。调试可以暂时让片元着色器直接返回fixed4(i.worldNormal * 0.5 0.5, 1)来可视化法线贴图检查法线是否平滑变化。性能开销大分析在Game视图打开Stats面板观察Batches和SetPass Calls。一个复杂的Shader可能会增加Draw Call。优化减少片元着色器中的复杂计算如多次sin/cos、pow。考虑将部分计算移到顶点着色器通过插值传递给片元。优化使用更简单的光照模型或者对于移动平台考虑使用烘焙光照贴图顶点动画的方案。7.2 效果优化技巧使用纹理动画代替顶点动画对于远处或次要的水面可以使用一张滚动的水面法线贴图Normal Map来模拟波纹性能开销远低于顶点动画。这需要在片元着色器中采样法线贴图并扰动法线。LOD细节层次为Shader编写多个SubShader或使用LOD指令根据摄像机距离切换不同复杂度的计算。远距离时关闭高光、使用更少的波纹叠加。GPU Instancing如果你的场景中有大量相同材质的水面物体如多个水池可以尝试在Shader中启用GPU Instancing来大幅降低Draw Call。在Shader的Properties块后面添加[PerRendererData]标签并做相应处理。7.3 扩展思路迈向更真实的水面完成这个基础版本后你可以尝试以下扩展打造更惊艳的效果菲涅尔效应在片元着色器中计算fresnel 1.0 - saturate(dot(viewDir, normal))用这个因子来混合反射颜色如天空盒和折射颜色水下颜色。反射与折射使用抓屏GrabPass获取背景纹理根据法线进行扰动模拟水面的折射扭曲效果。结合反射探头Reflection Probe来实现环境反射。泡沫与浪花在波纹的波峰即位移的特定相位处通过透明度测试或混合叠加一张泡沫纹理。与场景交互通过脚本将物体如船、角色的位置和速度信息传递给Shader使用Shader.SetGlobalVector在Shader中根据这些信息动态生成波纹实现物体划过水面的效果。从顶点到片元我们一步步构建了一个动态的水波纹Shader。这个过程的核心在于理解数据流CPU传递顶点数据到GPU顶点着色器塑造形状并准备数据片元着色器计算最终像素颜色。掌握了这个流程你就拥有了创造几乎任何表面视觉效果的基础能力。Shader编程是一个需要大量实践和试错的领域不要害怕修改参数、尝试不同的数学函数。最有效的学习方式就是像我们今天这样从一个具体的效果出发不断提出“如果我想让它……应该改哪里”的问题然后动手去验证。