Shader顶点动画实战:基于波动方程与噪声纹理实现旗帜飘扬效果

📅 2026/7/27 3:38:49
Shader顶点动画实战:基于波动方程与噪声纹理实现旗帜飘扬效果
1. 项目概述让旗帜“飘”起来的图形魔法在游戏、影视特效或者一些动态UI设计中我们常常需要让一面旗帜看起来像是在风中自然飘扬。如果只用传统的3D模型骨骼动画或者序列帧要么效果僵硬不自然要么资源开销巨大。这时候就该轮到Shader着色器大显身手了。今天要聊的就是用一段写在GPU上的代码驱动一张平面的纹理模拟出旗帜随风摆动的物理效果。这不仅仅是让画面“动起来”更是用数学和算法去模拟布料在风场中的受力与形变是一种非常高效且视觉效果出色的实时图形技术。简单来说这个项目的目标就是输入一张静态的旗帜贴图通过Shader实时计算其顶点位置和纹理采样坐标输出连续、流畅且富有物理感的飘扬动画。它不依赖预制的动画数据完全由GPU每帧实时演算因此旗帜的波动频率、幅度甚至风向都可以通过几个参数动态调整灵活性极高。无论你是Unity的Shader Graph用户还是喜欢手写HLSL/GLSL的硬核开发者理解其背后的原理都能让你在实现类似效果时事半功倍。2. 核心原理拆解波动方程与顶点变换旗帜飘扬的本质可以简化理解为一个二维平面旗帜网格在三维空间中的波动。这种波动不是随机的它遵循一定的物理规律我们可以用经典的波动方程来近似描述。2.1 波动模型的数学基础我们最常用的模型是正弦波Sine Wave的叠加。想象一下用手抖动一块桌布产生的波浪就是一系列正弦波的组合。在Shader中我们通常不会去解复杂的偏微分方程而是用一个简化但视觉效果足够好的公式来直接计算每个顶点的偏移量。核心的位移公式通常长这样displacement amplitude * sin(waveFrequency * (position.x position.z) time * waveSpeed phase)我们来拆解一下这个公式里的每个参数amplitude振幅决定了波浪起伏的高度。振幅越大旗帜飘扬的幅度就越夸张。waveFrequency波频决定了波浪的“密度”。频率高波浪看起来就多而密频率低波浪就少而疏。position.x position.z这是波的传播方向。通常我们用顶点的世界坐标或模型坐标的X和Z分量假设旗帜平铺在XZ平面Y轴向上来参与计算。position.x position.z意味着波沿着对角线方向传播。你也可以用dot(position, waveDirection)来指定任意方向的风向。time时间这是动画的灵魂。通过将时间变量_Time.y在Unity中_Time.y是自场景加载以来的总时间代入公式正弦函数的值会随时间周期性变化从而驱动顶点位置随时间变化产生动画。waveSpeed波速控制波浪移动的快慢。phase相位初始的偏移量可以用来制造波浪的错落感避免所有波纹同步显得不自然。注意单一的正弦波会显得非常规则和“假”。为了模拟更自然的布料效果我们几乎总是会使用多个不同频率、振幅和相位的正弦波进行叠加。比如一个低频大振幅的波模拟整体的摆动再叠加一个高频小振幅的波模拟布料的细微褶皱。2.2 顶点着色器中的实现流程在Shader的顶点着色器Vertex Shader阶段我们按照以下流程操作获取原始顶点信息输入的是旗帜模型通常是一个细分程度较高的平面网格的原始顶点位置positionOS和纹理坐标uv。计算顶点偏移量根据上述波动公式计算出该顶点在当前帧应该沿着法线方向通常是Y轴偏移的距离。为了效果更丰富我们可能会让UV坐标也参与计算产生纹理的扭曲感。应用偏移将计算出的偏移量一个float值乘以顶点的法线方向向量normalOS得到实际的位移向量然后加到原始顶点位置上。坐标空间转换将偏移后的顶点位置从模型空间Object Space转换到齐次裁剪空间Homogeneous Clip Space输出给光栅化阶段。这里有一个关键技巧我们通常使用顶点自身的XZ坐标和UV坐标来采样一个噪声纹理Noise Texture用噪声值来调制正弦波的参数如振幅或频率。这样能打破正弦波完美的周期性让旗帜的褶皱看起来更随机、更有机就像真实布料受风时应力分布不均一样。3. 完整Shader实现与代码解析下面我们以一个Unity URP通用渲染管线的手写HLSL Shader为例来逐步构建这个旗帜飘扬效果。我们会创建一个完整的Unlit Shader专注于动画本身。3.1 属性定义与变量声明首先在Shader的Properties块和CBUFFER中定义可供美术或策划调节的参数。Shader Custom/FlagWave { Properties { _MainTex (旗帜纹理, 2D) white {} _Color (颜色, Color) (1,1,1,1) // 波动参数 _WaveAmplitude (波浪振幅, Range(0, 2)) 0.5 _WaveFrequency (波浪频率, Range(0, 10)) 2.0 _WaveSpeed (波浪速度, Range(0, 5)) 1.0 // 噪声参数 _NoiseTex (噪声纹理, 2D) gray {} _NoiseStrength (噪声强度, Range(0, 1)) 0.3 _NoiseScale (噪声缩放, Range(0.1, 10)) 1.0 // 风向 _WindDirection (风向 (XY), Vector) (1, 0, 0, 0) } SubShader { ... // Pass等标签定义 HLSLINCLUDE #include Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl TEXTURE2D(_MainTex); SAMPLER(sampler_MainTex); TEXTURE2D(_NoiseTex); SAMPLER(sampler_NoiseTex); CBUFFER_START(UnityPerMaterial) float4 _MainTex_ST; half4 _Color; float _WaveAmplitude; float _WaveFrequency; float _WaveSpeed; float _NoiseStrength; float _NoiseScale; float2 _WindDirection; CBUFFER_END ...3.2 顶点着色器计算波动位移这是最核心的部分。我们编写顶点着色器函数计算每个顶点的最终位置。struct Attributes { float4 positionOS : POSITION; float2 uv : TEXCOORD0; float3 normalOS : NORMAL; }; struct Varyings { float4 positionCS : SV_POSITION; float2 uv : TEXCOORD0; }; Varyings vert(Attributes IN) { Varyings OUT; // 1. 基础UV变换 OUT.uv TRANSFORM_TEX(IN.uv, _MainTex); // 2. 计算波动因子 // 使用模型空间XZ坐标决定波的位置结合风向 float2 windFactor float2(IN.positionOS.x, IN.positionOS.z) * _WindDirection; float wave windFactor.x windFactor.y; // 3. 引入噪声扰动让波动更自然 // 对噪声纹理进行采样用世界坐标或模型坐标乘以缩放系数 float2 noiseUV IN.positionOS.xz * _NoiseScale; float noise SAMPLE_TEXTURE2D_LOD(_NoiseTex, sampler_NoiseTex, noiseUV, 0).r; // 将噪声映射到[-1, 1]范围用于扰动 noise noise * 2.0 - 1.0; // 4. 核心波动计算 // 将噪声叠加到波的位置计算中并加入时间变量 float waveInput _WaveFrequency * wave _Time.y * _WaveSpeed noise * _NoiseStrength; // 计算正弦值作为位移系数 float displacementFactor sin(waveInput); // 可以尝试使用多个频率叠加这里简单示例一个 // float displacementFactor sin(waveInput) * 0.7 sin(waveInput * 2.3 1.0) * 0.3; // 5. 计算最终位移 // 位移量 振幅 * 位移系数 * 法线方向 float3 displacement IN.normalOS * (_WaveAmplitude * displacementFactor); // 应用位移到顶点位置 float3 positionWS TransformObjectToWorld(IN.positionOS.xyz displacement); // 6. 输出裁剪空间位置 OUT.positionCS TransformWorldToHClip(positionWS); return OUT; }实操心得_Time.y是累积时间动画是连续的。如果你需要动画可暂停或受时间缩放影响可以考虑使用_Time.y * unity_DeltaTime.x的某种形式或者自己传递一个受游戏逻辑控制的时间变量。另外对噪声纹理进行采样时我使用了SAMPLE_TEXTURE2D_LOD并指定LOD为0确保采样的是最高精度避免动画闪烁。在实际项目中如果旗帜网格很密集也可以考虑将噪声计算移到片段着色器用插值后的UV来采样性能更高。3.3 片段着色器渲染旗帜颜色顶点着色器处理了形状片段着色器就负责上色。这里很简单就是采样主纹理并乘以颜色。half4 frag(Varyings IN) : SV_Target { half4 col SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, IN.uv); col * _Color; return col; }为了让旗帜在波动时纹理也有跟随形变的感觉而不仅仅是顶点移动一个高级技巧是在顶点着色器中同样扰动传递给片段着色器的UV坐标。例如可以根据顶点的位移强度对UV做一个轻微的偏移。这能模拟布料拉伸时图案的变形效果更逼真。// 在顶点着色器Varyings结构体中增加一个可能受扰动的UV // struct Varyings { ... float2 uvWave : TEXCOORD1; ... }; // 在vert函数中计算 // float2 uvOffset float2(displacementFactor * 0.05, 0); // 简单示例沿U方向偏移 // OUT.uvWave IN.uv uvOffset; // 在frag函数中使用OUT.uvWave进行纹理采样4. 网格与材质设置要点Shader写好了还需要在Unity编辑器中正确设置才能看到效果。4.1 旗帜网格的准备旗帜本身是一个3D模型。为了有足够的顶点供Shader变形你需要一个细分程度足够高的平面网格。一个只有4个顶点的Quad面片是远远不够的因为顶点太少波动会非常生硬像一块硬板在扭动。推荐方法在3D建模软件如Blender, Maya中创建一个平面并增加大量的循环边细分。或者在Unity中也可以使用Procedural Mesh程序化网格在运行时生成高细分网格。细分程度参考对于一个标准大小的旗帜长宽方向各有30-50个顶点分段就能获得比较平滑的波动效果。具体数量需要在性能和效果间权衡。4.2 材质参数调试将写好的Shader赋给一个材质球然后拖给旗帜模型。接下来就是快乐的调试时间《波浪振幅》从0开始调大观察旗帜起伏的高度。值太大会穿模或显得夸张。《波浪频率》调整波浪的密集程度。频率低是缓慢的大波浪频率高是急促的细碎波纹。《波浪速度》控制动画播放的快慢模拟风的大小。《噪声纹理》找一张灰度噪声图如Perlin噪声。这是打破规律性的关键。_NoiseStrength控制噪声的影响力度。《风向》调整_WindDirection的X和Y分量。比如(1,0)是沿X轴方向吹(0,1)是沿Z轴方向吹(0.707, 0.707)则是45度角吹。可以把这个参数暴露给游戏逻辑实现随风向变化的旗帜。注意事项调试时最好在Game视图而非Scene视图进行因为一些基于时间的计算如_Time.y在编辑模式下可能不会持续更新。同时可以勾选材质的Wireframe显示模式直观地观察顶点是如何被推挤的。5. 性能优化与进阶技巧一个基础的效果实现了但在实际项目尤其是移动端或大量使用的情况下我们需要考虑优化和增强。5.1 性能优化策略减少顶点数量在满足视觉效果的前提下尽可能减少旗帜网格的顶点数。可以通过LOD多层次细节系统为远处的旗帜使用低模版本和简化版的Shader。简化Shader计算如果风向固定可以预先计算wave dot(positionOS.xz, _WindDirection)而不是分别乘加。考虑将噪声采样从顶点着色器移到片段着色器。虽然这会增加片元计算量但对于高细分模型能显著减少顶点着色器的负担和顶点数据插值量。需要测试对比。减少叠加的正弦波数量。通常2-3层叠加已经能获得很好的效果。使用Shader变体与多编译如果你的项目中有不同质量的设置可以为旗帜Shader设置多个变体。例如低配版去掉噪声和复杂的波叠加高配版保留所有效果。通过#pragma multi_compile指令来控制。5.2 视觉效果增强法线贴图扰动目前的波动只改变了顶点位置旗帜表面的光照是平的。我们可以根据顶点位移动态计算或扰动法线。一种简单的方法是在顶点着色器中根据相邻顶点的位移差来近似计算新的法线向量并传递给片段着色器用于光照计算。这能让旗帜在光照下产生明暗变化立体感暴增。双面渲染默认情况下Unity会剔除背面Backface Culling。对于飘扬的旗帜我们很可能看到其背面。在SubShader或Pass中设置Cull Off来禁用剔除或者为背面使用一个更简单的着色方案。与风场系统交互不要将_WaveAmplitude和_WindDirection仅仅视为材质参数。它们应该与游戏世界中的风区Wind Zone关联起来。例如在树林中旗帜飘动剧烈在室内旗帜轻微摆动甚至静止。这需要脚本在运行时动态修改材质属性。// 一个简单的C#脚本示例驱动材质参数 public class FlagController : MonoBehaviour { public Renderer flagRenderer; public float windStrength 1.0f; public Vector2 windDirection Vector2.right; private MaterialPropertyBlock _propBlock; void Update() { if (_propBlock null) _propBlock new MaterialPropertyBlock(); flagRenderer.GetPropertyBlock(_propBlock); // 将风力和风向传递给Shader _propBlock.SetFloat(_WaveAmplitude, windStrength); _propBlock.SetVector(_WindDirection, windDirection); flagRenderer.SetPropertyBlock(_propBlock); } }使用顶点动画纹理VAT对于极其复杂、且对性能不敏感的旗帜动画比如电影级特写可以预先在DCC软件如Houdini中用高精度物理模拟计算出每一帧顶点的位置烘焙成一张顶点动画纹理。然后在Shader中根据时间采样这张纹理来驱动顶点位置。这是“离线模拟实时播放”效果最好但内存占用大且动画是固定的。6. 常见问题与排查实录在实际开发中你肯定会遇到一些“坑”。下面是我总结的几个典型问题及其解决方法。6.1 动画闪烁或抖动问题描述旗帜动画不流畅出现高频闪烁或跳跃式的抖动。可能原因与解决时间变量精度问题在极长时间运行后_Time.y的值会非常大可能导致浮点数精度丢失。对于周期性的正弦函数这可能会引起跳变。可以考虑对时间取模例如float modTime fmod(_Time.y, 100.0);使用模运算后的时间。噪声纹理采样问题如果噪声纹理的过滤模式Filter Mode设置为Point无过滤在顶点着色器中采样时由于顶点UV是连续变化的可能会在纹理像素之间跳跃导致动画抖动。务必确保噪声纹理的过滤模式设置为Bilinear或Trilinear。网格顶点密度不足网格太粗糙顶点太少导致位移变化不连续视觉上就是抖动。提高网格细分程度。6.2 旗帜边缘撕裂或穿模问题描述旗帜在剧烈波动时边缘顶点分离或者旗帜穿透了旗杆等相邻物体。可能原因与解决振幅过大_WaveAmplitude值设得太高导致顶点位移范围超过了合理区域。需要根据旗帜模型的实际尺寸调整振幅。缺乏约束真实的旗帜有一边是固定在旗杆上的。我们的Shader目前对所有顶点一视同仁。改进方法在位移计算中引入一个“固定边”的权重。例如用顶点U坐标假设U0是固定边U1是自由边作为一个衰减因子float fixedFactor IN.uv.x; // 假设UV从左到右展开然后将位移乘以fixedFactor。这样固定边的位移为0自由边位移最大中间平滑过渡。float fixedFactor smoothstep(0.0, 0.2, IN.uv.x); // 让前20%的UV范围固定边衰减更快 float3 displacement IN.normalOS * (_WaveAmplitude * displacementFactor * fixedFactor);6.3 性能热点分析问题描述场景中放置多面旗帜后帧率明显下降。排查工具使用Unity的Frame Debugger或ProfilerGPU模块。优化方向查看Draw Call确保所有旗帜材质使用了相同的Shader并尽可能合并批次Batch。使用材质属性块MaterialPropertyBlock来差异化参数而不是创建大量不同的材质实例。查看GPU耗时在Profiler中定位到你的旗帜Shader看是顶点处理费时还是片段处理费时。如果是顶点费时考虑降低网格精度或简化顶点着色器计算如移除非必要的噪声采样。如果是片段费时通常是因为Overdraw即多个旗帜重叠或屏幕占比大考虑使用更简单的片段着色器或者为远处的旗帜启用Alpha Test/Clip来丢弃透明片段。6.4 在URP/HDRP中的特殊处理问题描述Shader在Built-in管线工作正常迁移到URP后不生效或报错。关键点头文件与函数库必须包含URP的核心库如Core.hlsl而不是UnityCG.cginc。坐标变换函数使用URP提供的函数如TransformObjectToWorld和TransformWorldToHClip而不是旧的mul(UNITY_MATRIX_MVP, pos)。变量声明纹理和采样器使用TEXTURE2D/SAMPLER宏并通过CBUFFER声明材质属性。Shader变体URP对Shader变体的管理更严格如果需要多编译要使用URP支持的#pragma multi_compile指令集。实现一面飘扬的旗帜从简单的正弦波公式到融入噪声、法线扰动、物理约束是一个逐步深入理解顶点动画和GPU编程的过程。最关键的不是记住代码而是理解“将时间、空间坐标输入一个波形函数输出位移”这一核心思想。掌握了这个你就能举一反三创造出水面涟漪、草地摆动、头发飘动等各种各样的动态效果。调试时耐心调整每一个参数观察其视觉影响你会对Shader如何控制画面有更直觉的把握。最后别忘了在真机上测试性能毕竟再酷的效果如果让游戏卡成幻灯片也就失去了意义。