Unity URP顶点波浪动画Shader实战:从原理到性能优化

📅 2026/7/12 12:23:51
Unity URP顶点波浪动画Shader实战:从原理到性能优化
1. 项目概述从静态到动态的顶点魔法在Unity的视觉表现领域Shader是赋予模型灵魂的关键。我们常常会看到一些令人印象深刻的动态效果比如波光粼粼的湖面、随风摇曳的草地或是科幻场景中能量涌动的表面。这些效果的核心往往不是通过复杂的粒子系统或骨骼动画实现的而是通过一种更为底层、高效且灵活的技术——顶点动画。今天要拆解的“顶点波浪动画”正是顶点动画中最经典、应用最广泛的一种形式。它通过在Shader的顶点着色器阶段用数学函数实时修改网格顶点的位置从而让一个静态的平面“活”起来模拟出波浪的动态起伏。为什么选择在URP管线中实现这背后有很实际的考量。URP作为Unity当前主推的轻量级、高性能渲染管线其Shader编写框架与传统的内置管线或HDRP都有所不同。很多从内置管线转过来的开发者在编写URP Shader时尤其是在处理顶点变换和光照时容易踩坑。因此掌握在URP下编写顶点动画Shader不仅是为了实现效果更是为了适应现代Unity开发的主流技术栈。这个教程的目标就是带你绕过那些晦涩的理论直接进入实战从零开始手把手构建一个性能优秀、效果可控的顶点波浪Shader并让你彻底理解每一行代码背后的“为什么”。2. 核心原理与数学基础正弦波的舞蹈在开始写代码之前我们必须先理解驱动波浪动画的核心波函数。虽然自然界中的波浪非常复杂但在实时渲染中我们通常用一些经典的数学函数来近似模拟以求在视觉效果和计算开销之间取得平衡。2.1 正弦函数一切的基础最常用、最基础的波函数就是正弦函数sin(x)。它的图像是一条平滑、周期性起伏的曲线完美契合了波浪起伏的直观感受。在Shader中我们不会直接使用静态的sin(x)而是会构造一个动态的、受多个参数控制的波形函数。一个基础的动态正弦波公式通常如下waveHeight amplitude * sin(frequency * (position.x position.z) time * speed)我们来拆解这个公式里的每一个变量Amplitude (振幅)决定了波浪起伏的高度。值越大波峰与波谷的落差就越大。Frequency (频率)决定了波浪的“密度”或“波长”。频率越高在单位空间内波峰波谷的数量就越多波浪看起来就越“急促”。Time (时间)这是让波浪动起来的关键。通过将时间变量_Time.y表示自游戏开始以来的秒数引入函数sin函数的值会随时间连续变化从而驱动顶点位置持续偏移形成动画。Speed (速度)控制时间影响的系数可以理解为波传播的速度。Position (顶点位置)通常使用顶点的世界空间或模型空间的X、Z坐标作为输入。这意味着波浪的起伏会沿着空间坐标轴方向传播形成有方向性的波纹。注意这里使用(position.x position.z)而不是单一的坐标轴是为了产生沿着对角线方向传播的波纹视觉效果上比单纯沿X或Z轴传播更自然更像水面。你也可以尝试(position.x)或(position.x * direction.x position.z * direction.z)来获得特定方向的波浪。2.2 叠加与复合创造更丰富的细节单一的正弦波看起来会有些单调和“假”。真实世界的波浪是多种频率、振幅的波叠加的结果。因此一个高质量的波浪Shader通常会使用多个波函数叠加。例如我们可以叠加两个波totalOffset A1 * sin(F1 * pos T * S1) A2 * sin(F2 * pos T * S2 phase)其中A2通常比A1小F2比F1高。这样第一个波主波负责大的、缓慢的起伏第二个波细节波则在大波之上叠加了细碎的高频波纹极大地增强了表面的细节感和真实感。phase是相位偏移让两个波不同步避免产生过于规律的图案。2.3 从公式到顶点位移理解了波函数如何计算出一个偏移量totalOffset后我们需要将这个标量值应用到顶点上。通常我们只希望顶点沿着世界空间或模型空间的Y轴向上方向移动以模拟水面上下起伏。因此在顶点着色器中我们通常会这样操作float3 worldPos TransformObjectToWorld(vertexPositionOS.xyz); // 转换到世界空间float waveOffset calculateWave(worldPos.xz, _Time.y); // 计算Y轴偏移量worldPos.y waveOffset; // 应用偏移output.positionWS worldPos; // 输出新的世界位置output.positionCS TransformWorldToHClip(worldPos); // 用新位置计算裁剪空间位置这里有一个关键选择是在模型空间Object Space还是世界空间World Space计算波浪两种方式各有优劣模型空间波浪动画是基于模型自身的坐标。如果模型移动或旋转波浪不会随之改变方向看起来像是“粘”在模型上的图案。适合做角色身上的特效、飘动的披风等。世界空间波浪动画是基于绝对的世界坐标。无论模型如何移动旋转波浪的起伏方向如从东向西是固定的。这是模拟水面、地面等环境效果的首选因为它能确保多个使用同一Shader的物体如相连的水面板块其波浪是连续、统一的不会因为物体位置不同而出现接缝。在本实战中我们将采用世界空间计算以实现更通用的环境水面效果。3. URP Shader框架搭建与属性定义理论铺垫完成现在打开Unity创建一个新的Shader文件。URP Shader的结构与内置管线不同我们需要遵循它的模板。3.1 创建Shader与基本结构在Project窗口中右键选择Create - Shader - Universal Render Pipeline - Unlit Shader。从一个Unlit无光照Shader开始是最清晰的因为它只处理最基本的颜色和纹理没有复杂的光照模型干扰让我们可以专注于顶点动画本身。将其命名为“URPVertexWave”。打开这个Shader文件你会看到一个基本的URP Unlit Shader模板。它主要包含以下几个关键部分Shader “...” {}Shader的根块定义了资源路径和名称。Properties {}定义在材质面板中暴露给美术或设计师调整的参数。SubShader {}核心渲染逻辑所在包含Pass块。HLSLINCLUDE 和 HLSLPROGRAMURP使用HLSL语言编写Shader核心代码这些关键字标记了代码块的开始。我们的修改将主要集中在Properties、顶点着色器输入输出结构体以及顶点着色器函数本身。3.2 定义可调节的材质属性在Properties块中我们需要定义所有控制波浪效果的参数。一个好的Shader应该提供充分的可调性。以下是建议的属性列表Properties { [MainTexture] _BaseMap(Albedo (RGB), 2D) white {} [MainColor] _BaseColor(Base Color, Color) (1,1,1,1) // 波浪参数组 [Header(Wave Settings)] _WaveA_Amplitude(Wave A Amplitude, Range(0, 2)) 0.5 _WaveA_Frequency(Wave A Frequency, Range(0, 5)) 1.0 _WaveA_Speed(Wave A Speed, Range(-5, 5)) 1.0 _WaveA_Direction(Wave A Direction, Vector) (1, 0, 0, 0) // XZ方向 _WaveB_Amplitude(Wave B Amplitude, Range(0, 1)) 0.2 _WaveB_Frequency(Wave B Frequency, Range(0, 10)) 3.0 _WaveB_Speed(Wave B Speed, Range(-5, 5)) 1.5 _WaveB_Direction(Wave B Direction, Vector) (0, 1, 0, 0) // 视觉增强 [Header(Visual Enhancement)] _NormalMap(Normal Map, 2D) bump {} _NormalStrength(Normal Strength, Range(0, 2)) 1.0 _Smoothness(Smoothness, Range(0, 1)) 0.5 _Specular(Specular Color, Color) (0.1, 0.1, 0.1, 1) }参数设计解析分组建模使用[Header()]将属性在材质面板上分组使界面更清晰。两套波浪参数我们定义了两组完整的波浪参数A和B用于实现之前提到的波叠加以增加细节。方向向量_WaveX_Direction是一个Vector类型在Inspector中是四个浮点数但我们只使用它的x和z分量来构成一个二维方向向量。这允许我们精确控制每一层波浪传播的方向而不是固定沿对角线。法线贴图与光照虽然我们从Unlit Shader开始但为了最终效果提前预留了法线贴图、光滑度和高光颜色属性。这为我们后续升级到简单的光照模型如Simple Lit做好了准备。3.3 声明变量与定义结构体在HLSLPROGRAM代码区域我们需要用CBUFFER_START(UnityPerMaterial)和CBUFFER_END来声明与Properties对应的变量这是URP的标准做法确保材质属性数据被正确传递。接着定义顶点着色器的输入和输出结构体。输入结构体Attributes通常包含顶点位置、法线、UV等。输出结构体Varyings用于从顶点着色器向片元着色器传递数据。对于顶点动画最关键的是我们需要在顶点着色器中计算新的世界空间位置。// 输入结构体 struct Attributes { float4 positionOS : POSITION; float3 normalOS : NORMAL; float4 tangentOS : TANGENT; float2 texcoord : TEXCOORD0; UNITY_VERTEX_INPUT_INSTANCE_ID }; // 输出结构体 struct Varyings { float4 positionCS : SV_POSITION; float2 uv : TEXCOORD0; float3 positionWS : TEXCOORD1; // 新增传递世界空间位置 float3 normalWS : TEXCOORD2; // 新增传递世界空间法线用于光照 UNITY_VERTEX_INPUT_INSTANCE_ID UNITY_VERTEX_OUTPUT_STEREO };这里的关键改动是在Varyings结构体中增加了positionWS和normalWS。positionWS是经过波浪偏移后的顶点世界坐标它将用于片元着色器中计算基于世界空间的纹理采样如全局雾效或更复杂的效果。normalWS是变换到世界空间的法线为后续添加光照计算做准备。4. 顶点着色器核心实现让网格起伏起来这是整个Shader的心脏。我们将在顶点着色器函数VertexShader中完成所有计算。4.1 波浪计算函数的封装首先我们定义一个可复用的函数来计算单个波浪的偏移量。这会让主逻辑更清晰。float CalculateWaveOffset(float3 worldPos, float amplitude, float frequency, float speed, float2 direction) { // 将方向向量归一化确保其长度为1避免影响频率 direction normalize(direction); // 计算波函数的输入值顶点在世界XZ平面上的投影与方向向量的点积加上时间 float waveInput dot(worldPos.xz, direction) * frequency _Time.y * speed; // 返回正弦波计算结果 return amplitude * sin(waveInput); }这个函数封装了核心公式。使用点积dot(worldPos.xz, direction)是一个非常巧妙的做法它计算了顶点位置在波浪传播方向上的投影长度。这意味着无论方向向量指向哪里波浪都会沿着那个方向传播实现了对波浪方向的完全控制。4.2 整合与顶点变换现在在VertexShader函数中调用这个函数并叠加多层波浪。Varyings VertexShader(Attributes input) { Varyings output (Varyings)0; UNITY_SETUP_INSTANCE_ID(input); UNITY_TRANSFER_INSTANCE_ID(input, output); UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(output); // 1. 将顶点从模型空间变换到世界空间变换前的位置 float3 positionWS TransformObjectToWorld(input.positionOS.xyz); float3 normalWS TransformObjectToWorldNormal(input.normalOS); // 2. 计算波浪叠加偏移量 float waveOffset 0; waveOffset CalculateWaveOffset(positionWS, _WaveA_Amplitude, _WaveA_Frequency, _WaveA_Speed, _WaveA_Direction.xz); waveOffset CalculateWaveOffset(positionWS, _WaveB_Amplitude, _WaveB_Frequency, _WaveB_Speed, _WaveB_Direction.xz); // 3. 应用偏移到世界空间位置的Y轴 positionWS.y waveOffset; // 4. 修正法线近似处理 // 这是一个简化处理。严格来说顶点移动后法线应该重新计算通过求相邻顶点的差分。 // 但对于平滑波浪且网格细分足够的情况下沿用原始法线或进行简单偏移是可以接受的。 // 更准确的方法是传递顶点原始世界坐标和偏移量到片元着色器在那里基于波函数导数计算精确法线。 // 此处为简化我们先不做处理后续可升级。 // normalWS normalize(normalWS); // 可先保持原样或重新归一化 // 5. 将新的世界空间位置转换到齐次裁剪空间 output.positionCS TransformWorldToHClip(positionWS); output.positionWS positionWS; // 传递新的世界坐标 output.normalWS normalWS; output.uv TRANSFORM_TEX(input.texcoord, _BaseMap); // 处理纹理UV偏移和缩放 return output; }关键步骤解析初始变换首先我们将顶点的位置和法线从模型空间变换到世界空间。注意此时的positionWS是应用波浪偏移前的位置。偏移叠加调用两次CalculateWaveOffset函数分别使用A波和B波的参数并将结果相加。这就是波叠加的实现。应用位移将计算出的总偏移量waveOffset加到世界空间位置的Y分量上。这是产生动画视觉效果最直接的一步。法线处理这是一个重要的注意事项。顶点移动后表面的朝向即法线其实已经改变了。如果我们后续要使用法线贴图或计算光照就必须使用正确的、与变形后表面垂直的法线。上面代码中注释提到了两种方法简单近似性能好精度低和精确计算性能开销稍大精度高。在第一个版本中为了优先保证动画功能我们可以暂时使用原始法线或忽略此问题。但在追求高质量反射/折射效果时必须解决它。最终输出使用偏移后的positionWS计算裁剪空间坐标positionCS这是GPU光栅化所必需的。同时我们将新的positionWS和normalWS存入输出结构体传递给片元着色器。4.3 片元着色器与基础渲染顶点着色器完成后片元着色器相对简单。我们先实现一个无光照的版本仅显示颜色和纹理。half4 FragmentShader(Varyings input) : SV_Target { UNITY_SETUP_INSTANCE_ID(input); UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(input); // 采样基础纹理和颜色 half4 baseColor SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, input.uv) * _BaseColor; // 可以在这里添加基于世界坐标的额外效果例如根据 positionWS.y 进行颜色渐变模拟水深 // half depthFactor saturate((input.positionWS.y - _WaterLevel) * _DepthGradient); // baseColor.rgb lerp(_DeepColor, _ShallowColor, depthFactor); return baseColor; }至此一个最基本的、具有可调波浪动画的URP Unlit Shader就完成了。你可以将其赋给一个平面Plane或细分网格调整材质面板上的波浪参数应该就能看到起伏的动画效果了。实操心得一网格细分的重要性顶点动画的效果质量极度依赖于模型的顶点密度。一个默认的Unity Plane10x10单位仅100个顶点几乎看不到平滑的波浪只会出现几个顶点的突兀跳动。你必须使用足够细分的网格。有两种方法1. 在3D建模软件如Blender中创建高细分平面再导入Unity2. 在Unity中可以为MeshFilter组件添加一个Subdivide Mesh的脚本或在导入设置中调整。通常一个200x200的网格顶点数才能获得比较平滑的波浪效果。这是实现顶点动画效果的第一个也是最重要的前提。5. 效果增强与性能优化实战基础波浪有了但它看起来可能还像是一个单调的、塑料片在动。接下来我们通过添加视觉特效和优化性能来提升它的质感。5.1 添加法线贴图与简单光照真实的波浪表面有复杂的镜面反射和高光。我们可以通过法线贴图来模拟这种微观几何细节。首先在Properties中我们已经声明了_NormalMap和_NormalStrength。现在需要修改片元着色器。我们需要在片元着色器中采样法线贴图并将其从切线空间转换到世界空间因为我们的光照计算将在世界空间进行。这要求我们在顶点着色器中计算并传递切线空间到世界空间的变换矩阵TBN矩阵的一部分。首先补充Varyings结构体添加切线信息struct Varyings { ... float3 normalWS : TEXCOORD2; float3 tangentWS : TEXCOORD3; // 新增世界空间切线 float3 bitangentWS : TEXCOORD4; // 新增世界空间副切线 ... };在顶点着色器中计算并传递这些向量// 在VertexShader函数内计算TBN矩阵所需向量 output.tangentWS TransformObjectToWorldDir(input.tangentOS.xyz); output.bitangentWS cross(output.normalWS, output.tangentWS) * input.tangentOS.w; // w分量决定副切线方向然后在片元着色器中我们可以采样法线贴图并进行变换然后进行简单的兰伯特Lambert漫反射和高光Blinn-Phong计算。这里我们假设有一个主方向光。half4 FragmentShader(Varyings input) : SV_Target { ... // 1. 采样和重建世界空间法线 half3 normalTS UnpackNormal(SAMPLE_TEXTURE2D(_NormalMap, sampler_NormalMap, input.uv)); normalTS.xy * _NormalStrength; // 应用法线强度 normalTS.z sqrt(1.0 - saturate(dot(normalTS.xy, normalTS.xy))); // 重新计算Z分量 // 构建TBN矩阵并变换到世界空间 float3x3 TBN float3x3(input.tangentWS, input.bitangentWS, input.normalWS); half3 normalWS normalize(mul(normalTS, TBN)); // 2. 简单光照计算假设只有一个主光 Light mainLight GetMainLight(); float3 lightDir mainLight.direction; float3 viewDir normalize(_WorldSpaceCameraPos.xyz - input.positionWS); // 漫反射 (Lambert) half NdotL saturate(dot(normalWS, lightDir)); half3 diffuse mainLight.color * NdotL * baseColor.rgb; // 高光 (Blinn-Phong) float3 halfDir normalize(lightDir viewDir); half NdotH saturate(dot(normalWS, halfDir)); half specularPower exp2(_Smoothness * 10 1); // 将0-1光滑度映射为合理的光泽度 half3 specular mainLight.color * _Specular.rgb * pow(NdotH, specularPower); // 3. 组合最终颜色环境光漫反射高光 half3 ambient half3(0.1, 0.1, 0.1) * baseColor.rgb; // 简单环境光 half3 finalColor ambient diffuse specular; return half4(finalColor, baseColor.a); }添加法线和光照后波浪表面立刻有了明暗变化和光泽感真实度大幅提升。你可以使用一张水波纹的法线贴图来获得更好的效果。5.2 基于顶点偏移的精确法线计算前面提到顶点移动后使用原始法线是不准确的。对于要求高的项目如清澈的水体我们需要计算变形后的精确法线。这可以通过在片元着色器中对世界空间位置positionWS求偏导数来实现。原理是表面的法线可以通过表面上某一点在两个切线方向通常是X和Z方向上的高度变化率即梯度来计算。由于我们在Shader中已经有了世界坐标positionWS而positionWS.y就是我们的高度函数H(x,z) waveFunction(x,z)。那么法线可以近似为normal normalize(float3(-dH/dx, 1.0, -dH/dz))其中dH/dx和dH/dz是高度函数在x和z方向的偏导数。我们可以在片元着色器中使用ddx和ddy函数来近似计算这些导数。ddx计算当前片元与其在屏幕空间X方向相邻片元之间的差值ddy则是Y方向。// 在片元着色器中替换掉从法线贴图获取normalWS的步骤 float3 worldPos input.positionWS; // 使用屏幕空间差分求世界空间高度的偏导这是一个近似但在网格细分足够时效果很好 float dhdx ddx(worldPos.y); float dhdz ddy(worldPos.y); // 注意在屏幕空间ddy对应的是世界空间的Z方向这里需要根据坐标系调整。更严谨的做法是 // 构建精确的切线向量 float3 dpdx ddx(worldPos); float3 dpdz ddy(worldPos); // 获取世界坐标在屏幕X和Y方向的变化向量 // 法线 dpdx 和 dpdz 的叉积并归一化。这直接得到了变形后表面的几何法线。 float3 geometricNormalWS normalize(cross(dpdz, dpdx)); // 注意叉乘顺序以确保法线朝上 // 然后可以将这个几何法线与法线贴图采样结果混合得到最终法线。这种方法计算出的法线是几何法线它完全反映了顶点位移后的真实表面朝向非常适合用于计算精确的光照和反射。但请注意ddx/ddy指令在某些平台如移动端上可能有性能开销或精度问题需要测试。5.3 性能优化关键点顶点动画Shader虽然高效但在移动平台或大面积使用时仍需注意性能。计算频率优化我们的波浪计算是在顶点着色器中进行的。这意味着计算量取决于顶点数量。这就是为什么我们要控制网格细分程度在效果可接受的情况下使用尽可能少的顶点。对于超大面积的水面可以考虑使用顶点纹理动画Vertex Texture Animation技术将预计算的波浪高度图存储在纹理中在顶点着色器中采样将复杂的函数计算转换为纹理查找这在某些情况下可能更快尤其是波形固定时。波函数简化sin函数计算开销相对较低但如果你叠加了太多层如4-5层计算量仍会累积。可以考虑使用更简单的周期函数或者将一些低频、高振幅的波计算转移到顶点着色器而将高频、低振幅的细节波通过法线贴图来模拟这是一种常见的性能与效果权衡。LOD细节层次对于远离相机的波浪物体可以使用更简单的Shader变体减少波叠加层数或更低精度的网格通过LOD Group组件来管理。GPU Instancing支持确保你的Shader支持GPU Instancing。对于大量重复使用同一波浪材质的不同物体如一片草地Instancing可以极大减少Draw Call。在URP Shader中正确使用UNITY_VERTEX_INPUT_INSTANCE_ID等宏通常就能开启支持。6. 常见问题排查与调试技巧在实际开发中你几乎一定会遇到各种问题。下面是一些典型问题及其解决方法。6.1 波浪没有动画效果检查时间变量确保你在波浪计算中使用了_Time.y或_Time.x。_Time是一个float4向量其分量分别是t/20, t, t2, t3。通常使用_Time.y作为以秒为单位的时间。检查参数范围速度_Speed参数是否设置为0振幅_Amplitude是否太小在材质面板中调整这些值观察变化。检查坐标空间确认你是在世界空间positionWS还是模型空间计算波浪。如果是世界空间但物体发生了旋转波浪方向可能不符合预期。可以尝试在模型空间计算看看效果。6.2 波浪出现尖锐的“锯齿”或断裂网格细分不足这是最常见的原因。如前所述增加网格的顶点密度。数值精度问题在顶点着色器中如果世界坐标值非常大例如物体离世界原点很远sin函数输入值过大可能导致精度丢失。可以考虑在计算前对世界坐标取模或使用相对坐标。一种技巧是float waveInput dot(frac(worldPos.xz * 0.01), direction) * frequency _Time.y * speed;使用frac函数取小数部分创造一种无限重复的平铺波浪图案同时避免大数运算。6.3 多个水面板块接缝处波浪不连续原因如果每个水面板块是独立的GameObject且使用模型空间计算波浪那么每个板块的波浪都是独立计算的接缝处必然不连续。解决方案必须使用世界空间坐标进行计算。确保所有水面板块的Shader都使用相同的世界空间波浪函数这样无论板块如何摆放它们的波浪都是基于同一个全局坐标系生成的接缝处就能完美衔接。6.4 法线看起来奇怪光照闪烁或错误法线未归一化在顶点或片元着色器中对法线、切线等向量进行运算后必须使用normalize()函数重新归一化否则长度不为1会导致光照计算错误。切线空间转换错误检查TBN矩阵的构建是否正确特别是副切线bitangent的计算中是否乘了input.tangentOS.w这个w分量通常是1或-1用于处理镜像UV。使用ddx/ddy时的注意事项ddx和ddy在屏幕边缘或三角形背面可能产生未定义结果。确保你的计算在分支内是安全的或者考虑使用中心差分等更稳定的方法。6.5 在移动设备上性能不佳使用Shader变体为移动平台创建一个简化版的Shader减少波叠加层数关闭复杂的光照计算如高光使用更简单的法线计算如直接使用法线贴图不计算几何法线。分析渲染管线使用Unity的Frame Debugger或Profiler的Rendering模块查看Draw Call数量和顶点处理开销。确保使用了合批Batching或GPU Instancing。降低精度在片元着色器中将一些float计算改为half特别是在低端移动设备上这能减少带宽和计算压力。调试技巧在开发过程中可以创建一个临时的调试模式。例如在Shader Properties中添加一个_DebugMode开关在片元着色器中根据其值直接输出waveOffset、normalWS或positionWS.y作为颜色。这能让你直观地看到Shader中间计算的结果快速定位问题是出在波浪计算、法线变换还是光照环节。例如if (_DebugMode 1) return half4(waveOffset, waveOffset, waveOffset, 1);将偏移量可视化为灰度图。