Shader实现圆形水波进度动画:从原理到Unity实战
1. 项目概述为什么我们需要一个生动的进度条在游戏和交互应用里进度条无处不在。从资源加载、技能冷却到任务完成度它都是向玩家传递“还需要多久”这一核心信息的关键界面元素。然而传统的矩形填充进度条看久了难免单调缺乏情感和视觉冲击力。想象一下一个充满魔力的药水正在被注入一个能量核心正在被激活或者一个古老的符文正在被点亮——这些场景如果配上一个动态的、带有涟漪效果的圆形进度动画沉浸感将瞬间提升一个档次。这就是“圆形水波进度动画”的价值所在。它不仅仅是一个进度指示器更是一个强大的氛围营造工具。通过Shader着色器来实现这一效果意味着我们能获得极致的性能和灵活性。Shader运行在GPU上效率极高能够轻松实现平滑的动画和复杂的视觉效果且不占用宝贵的CPU资源。与使用序列帧动画或粒子系统相比Shader方案资源占用更小效果更可控参数调整也更为方便。无论是用于HUD界面的技能图标还是作为场景中可交互物体的视觉反馈一个精心制作的圆形水波动画都能让产品的质感脱颖而出。2. 核心思路拆解如何用Shader“画”出动态水波要实现这个效果我们需要拆解几个核心的图形学概念。Shader编程的本质是告诉GPU每个像素该如何着色。对于我们的圆形水波进度关键在于计算两个东西“这个像素在不在圆环内”和“它处于水波动画的哪个相位”。2.1 从画一个圆到画一个圆环首先我们需要在屏幕上定义出一个圆形区域。在Shader中最常用的方法是利用距离函数。我们通常将纹理坐标归一化到以某个中心点如(0.5, 0.5)为原点的坐标系中然后计算当前像素点到中心点的距离。// 示例计算到中心的距离 float2 uv i.uv - _Center; // 将UV坐标原点移至中心 float dist length(uv); // 计算欧几里得距离得到一个dist值后如何判断它在圆内我们可以用一个阈值即圆的半径_Radius来比较。如果dist _Radius我们就在圆内否则在圆外。通过step或smoothstep函数我们可以得到边缘清晰或带有抗锯齿的圆形遮罩。而圆环则是两个半径不同的同心圆之间的区域。假设内半径为_InnerRadius外半径为_OuterRadius那么一个像素在圆环内的条件就是dist _InnerRadius dist _OuterRadius。同样我们可以用smoothstep来获得平滑的圆环边缘这对于视觉效果至关重要。2.2 引入进度变量与环形裁剪进度条的核心是“填充”。在圆形进度中填充通常是从0度到360度顺时针或逆时针扫过。在Shader中我们可以通过角度来判断一个点是否属于已被“填充”的区域。计算角度通过atan2(uv.y, uv.x)函数我们可以根据像素点相对于中心的位置计算出其极坐标下的角度值范围通常在[-π, π]之间。我们需要将其映射到[0, 1]的进度空间。float angle atan2(uv.y, uv.x) / (2.0 * PI); // 映射到 [0, 1] angle frac(angle); // 确保在0-1之间处理负值应用进度裁剪我们有一个_Progress变量范围0到1。一个直观的想法是如果像素的角度值小于_Progress则它位于已填充区域。但这样得到的是一个从中心向右开始的扇形填充。为了更常见的顺时针填充我们可能需要对角度进行偏移和取反操作。结合圆环最终一个像素点要被显示必须同时满足两个条件位于圆环区域内且其角度小于当前进度值。我们可以将圆环遮罩与角度比较的结果相乘来得到最终的填充形状。2.3 创造水波涟漪效果静态的圆环填充还不够生动水波效果才是灵魂。水波的本质是随时间变化的波形扰动。我们可以将上面计算出的距离dist作为一个基础参数叠加一个正弦波或余弦波。基础波形最简化的水波可以用sin或cos函数来模拟。公式类似于wave sin(_WaveFrequency * dist - _Time.y * _WaveSpeed)。这里_WaveFrequency控制波的数量波纹密度_WaveSpeed控制波移动的速度_Time.y是Unity内置的累计时间变量。影响透明度或宽度这个wave值范围[-1, 1]可以用来做什么通常有两种思路调制圆环宽度将wave值映射到一个较小的范围然后加到圆环的判断条件中让圆环的边缘像波浪一样起伏。例如外半径变为_OuterRadius wave * _WaveAmplitude。调制颜色/透明度将wave值经过处理如(wave 1) * 0.5映射到[0,1]作为透明度系数或颜色亮度系数。这样圆环上就会产生明暗交替、向外扩散的波纹感。多层波叠加单一的正弦波看起来比较假。为了模拟更自然的水面我们可以叠加多个频率和速度不同的波。例如float wave1 sin(10.0 * dist - _Time.y * 3.0); float wave2 cos(15.0 * dist - _Time.y * 4.0 * 0.7); float combinedWave (wave1 wave2) * 0.5; // 平均或加权平均多层波的叠加能产生更复杂、更不规则的波纹图案效果更加逼真。2.4 整合与渲染将以上所有元素整合起来我们的片元着色器Fragment Shader大致会遵循以下流程计算UV偏移和到中心的距离。计算基础圆环遮罩可能使用平滑边缘。计算角度并根据_Progress进行裁剪得到进度填充遮罩。基于距离和时间计算一层或多层水波值。将水波值应用到圆环遮罩上改变其边缘或透明度。将最终的遮罩与颜色相乘输出像素颜色。通常我们还会考虑背景色、前景色以及水波高光色的混合。3. Shader实现详解从理论到代码理解了原理我们开始动手编写Unity Shader。这里我们将实现一个功能相对完整的版本包含平滑边缘、动态水波和颜色控制。3.1 属性定义与变量声明首先在Shader的Properties块中定义所有可供美术或策划在材质面板上调节的参数。Shader Custom/CircularWaveProgress { Properties { [Header(Base Settings)] _MainColor (Main Color, Color) (1, 0.5, 0, 1) // 进度条主色 _BackgroundColor (Background Color, Color) (0.2, 0.2, 0.2, 1) // 背景色 _WaveColor (Wave Highlight Color, Color) (1, 1, 0.8, 1) // 水波高光色 [Header(Circle Settings)] _Radius (Radius, Range(0, 0.5)) 0.4 // 整体半径 _InnerRadius (Inner Radius, Range(0, 0.5)) 0.3 // 内圆半径 _OuterRadius (Outer Radius, Range(0, 0.5)) 0.4 // 外圆半径 _Smoothness (Edge Smoothness, Range(0, 0.1)) 0.01 // 边缘平滑度 [Header(Progress Settings)] _Progress (Progress, Range(0, 1)) 0.75 // 进度值 _StartAngle (Start Angle Offset, Range(0, 360)) -90 // 起始角度偏移-90度使进度从顶部开始 [Header(Wave Settings)] _WaveFrequency (Wave Frequency, Float) 30.0 // 波纹频率 _WaveSpeed (Wave Speed, Float) 5.0 // 波纹移动速度 _WaveAmplitude (Wave Amplitude, Range(0, 0.1)) 0.02 // 波纹幅度 _WaveSharpness (Wave Sharpness, Range(1, 10)) 3.0 // 波纹锐利度用于pow函数 } SubShader { // ... Pass 和 Tags 定义 ... } }注意_Radius、_InnerRadius等范围设为[0, 0.5]是因为我们的UV默认范围是[0,1]中心在(0.5,0.5)最大半径不应超过0.5。在CGPROGRAM中我们需要声明与Properties对应的变量并定义一些常量。CGPROGRAM #pragma surface surf Lambert // 我们使用表面着色器简化光照对于UI效果也可用Unlit // 声明与Properties对应的变量 fixed4 _MainColor; fixed4 _BackgroundColor; fixed4 _WaveColor; float _Radius; float _InnerRadius; float _OuterRadius; float _Smoothness; float _Progress; float _StartAngle; float _WaveFrequency; float _WaveSpeed; float _WaveAmplitude; float _WaveSharpness; // 常量 #define PI 3.14159265359 #define TWO_PI 6.283185307183.2 核心计算函数我们将关键计算封装成函数使主逻辑更清晰。函数1绘制平滑圆环这个函数返回一个值表示当前UV点位于圆环内的程度0到10代表完全在外1代表完全在内中间值代表平滑过渡区。float DrawRing(float2 uv, float center, float innerRad, float outerRad, float smoothness) { float dist length(uv); // 绘制外圆边缘从outerRad开始向内平滑 float outerCircle smoothstep(outerRad smoothness, outerRad - smoothness, dist); // 绘制内圆边缘从innerRad开始向外平滑然后取反因为我们想要的是圆环外部区域 float innerCircle 1.0 - smoothstep(innerRad - smoothness, innerRad smoothness, dist); // 圆环是同时满足在外圆内和在内圆外 return outerCircle * innerCircle; }函数2根据进度进行角度裁剪这个函数返回一个值表示当前UV点是否在进度扇形区域内。float ApplyProgress(float2 uv, float progress, float startAngle) { // 计算角度并归一化到[0, 1] float angle atan2(uv.y, uv.x); angle angle / TWO_PI; // 范围[-0.5, 0.5] angle frac(angle 0.5); // 转换到[0, 1]0对应右方 // 应用起始角度偏移将startAngle从度转换为进度值0-1 float startProgress startAngle / 360.0; angle frac(angle - startProgress 1.0); // 确保在0-1内循环 // 比较如果角度小于进度则在扇形内 return step(angle, progress); }函数3生成水波扰动这个函数基于距离和时间生成一个波形值。float GenerateWave(float dist, float time) { // 基础正弦波 float wave sin(_WaveFrequency * dist - time * _WaveSpeed); // 通过pow函数增加波形的锐利度使波峰更尖波谷更平缓更像水波 wave pow(wave * 0.5 0.5, _WaveSharpness) * 2.0 - 1.0; // 保持[-1,1]范围但形状改变 return wave; }3.3 表面着色器主函数在surf函数中我们将所有部分组合起来。void surf (Input IN, inout SurfaceOutput o) { // 1. 将UV坐标中心化 float2 centerUV IN.uv_MainTex - float2(0.5, 0.5); // 2. 绘制基础圆环 float ringMask DrawRing(centerUV, 0.0, _InnerRadius, _OuterRadius, _Smoothness); // 3. 应用进度裁剪 float progressMask ApplyProgress(centerUV, _Progress, _StartAngle); float finalProgressRingMask ringMask * progressMask; // 4. 生成水波效果 float dist length(centerUV); float waveValue GenerateWave(dist, _Time.y); // 将波形值映射到对半径的影响上产生波动的边缘 float waveOffset waveValue * _WaveAmplitude; float dynamicOuterRadius _OuterRadius waveOffset; // 用动态半径重新计算一次圆环或者只计算边缘部分 float waveRingMask DrawRing(centerUV, 0.0, _InnerRadius, dynamicOuterRadius, _Smoothness * 0.5); // 水波边缘可以更锐利一点 float finalWaveMask waveRingMask * progressMask; // 5. 颜色混合 // 基础背景色 fixed4 finalColor _BackgroundColor; // 叠加进度条主色 finalColor lerp(finalColor, _MainColor, finalProgressRingMask); // 叠加水波高光色根据waveValue的强度只在波峰处叠加 float waveHighlight max(0, waveValue); // 只取波峰部分 finalColor lerp(finalColor, _WaveColor, finalWaveMask * waveHighlight); // 6. 输出 o.Albedo finalColor.rgb; o.Alpha finalColor.a; }实操心得在混合颜色时使用lerp线性插值函数是关键。lerp(a, b, t)会根据t在a和b之间混合。t可以是我们计算出的各种遮罩mask。这种“遮罩驱动”的思维在Shader编写中非常普遍。另外水波高光只叠加在波峰waveValue 0的部分这样能产生更逼真的光影变化模拟水面对光线的反射。4. 进阶优化与效果增强基础版本已经能跑了但要让效果更出彩我们还需要一些“魔法调料”。4.1 多层水波与随机性单层水波过于规则。我们可以叠加多层不同频率和速度的波并引入一些伪随机性。float GenerateMultiLayerWave(float dist, float time) { // 第一层主波频率高速度快 float wave1 sin(_WaveFrequency * 1.0 * dist - time * _WaveSpeed * 1.2); // 第二层次波频率稍低速度稍慢相位偏移 float wave2 cos(_WaveFrequency * 0.7 * dist - time * _WaveSpeed * 0.8 1.0); // 第三层长波频率低幅度小用于模拟底层涌动 float wave3 sin(_WaveFrequency * 0.3 * dist - time * _WaveSpeed * 0.4 2.4) * 0.5; // 合并可以赋予不同权重 float combinedWave wave1 * 0.5 wave2 * 0.35 wave3 * 0.15; // 应用锐利化 combinedWave pow(combinedWave * 0.5 0.5, _WaveSharpness) * 2.0 - 1.0; return combinedWave; }4.2 基于距离的波纹衰减真实的水波越往外扩散振幅会越小。我们可以让水波幅度随着距离增加而衰减。// 在GenerateWave函数内部或调用后加入衰减 float distanceFromCenter length(centerUV); float normalizedDist saturate((distanceFromCenter - _InnerRadius) / (_OuterRadius - _InnerRadius)); // 映射到圆环范围的[0,1] float attenuation 1.0 - normalizedDist; // 线性衰减中心强边缘弱 // 或者使用指数衰减边缘衰减更快 // float attenuation exp(-normalizedDist * 2.0); float waveValue GenerateMultiLayerWave(dist, _Time.y); waveValue * attenuation; // 应用衰减 float waveOffset waveValue * _WaveAmplitude;4.3 添加纹理与扭曲效果让水波不仅仅是颜色变化还能对底层纹理产生扭曲效果会更高级。这需要用到法线贴图或简单的UV偏移。采样法线贴图在Properties中添加一张法线贴图_BumpMap。在计算水波时根据waveValue对法线采样的UV进行扰动。// 在Input结构体中添加 struct Input { float2 uv_MainTex; float2 uv_BumpMap; // 法线贴图UV }; // 在surf函数中 float2 waveUVOffset float2(waveValue, waveValue) * _DistortStrength; // 扰动强度 fixed3 normal UnpackNormal(tex2D(_BumpMap, IN.uv_BumpMap waveUVOffset)); o.Normal normal; // 会影响光照计算简单的UV扭曲即使没有法线贴图也可以直接用水波值扰动主纹理的UV产生动态的扭曲感模拟光线的折射。float2 distortedUV IN.uv_MainTex float2(waveValue, -waveValue) * 0.01; fixed4 texColor tex2D(_MainTex, distortedUV); // 然后将texColor与你的进度颜色进行混合4.4 性能优化要点Shader虽好但也要注意性能。特别是如果在移动设备上大量使用。精度选择在片元着色器中对于颜色和范围在0-1之间的参数使用fixed或half精度通常就足够了比float更快。例如fixed4 _MainColor;。减少复杂计算sin,cos,pow都是相对耗时的操作。确保它们只在必要时调用。例如将TWO_PI这样的常量预计算好。避免分支GPU不喜欢if语句。尽量使用step(),smoothstep(),lerp()等内置函数来替代条件判断。我们上面的代码基本遵循了这一原则。针对UI优化如果这个Shader专门用于UIUGUI考虑使用UI/Default或自定义的Unlit Shader并关闭不必要的功能如雾效、光照。可以添加[PerRendererData]标签来处理动态材质属性以支持合批。5. 在Unity中的实际应用与调试写好Shader只是第一步把它用起来并调出理想效果同样重要。5.1 创建材质与参数调节在Project窗口中右键创建Material命名为Mat_CircularWaveProgress。将Material的Shader选择为我们刚创建的Custom/CircularWaveProgress。将这个材质赋给一个UI Image如果是UI或一个Quad/Plane如果是3D场景。现在你可以在材质Inspector面板上尽情调节参数了快速预览拖动_Progress滑块看到进度条填充。调整外观修改_MainColor、_Inner/OuterRadius来改变圆环粗细和颜色。调出水波感重点调节_WaveFrequency频率值越大波纹越密、_WaveSpeed速度、_WaveAmplitude幅度值越大波动越剧烈。先从一个参数开始调感受变化再组合调节。微调细节_Smoothness控制边缘羽化_WaveSharpness控制波纹是圆润还是尖锐。5.2 通过脚本动态控制进度进度条的生命在于“动”。我们需要在C#脚本中动态修改材质的_Progress属性。using UnityEngine; using UnityEngine.UI; // 如果是UI public class CircularProgressController : MonoBehaviour { public float progress 0f; // 目标进度值 public float animationSpeed 1.0f; // 动画速度 private Material _material; private float _currentProgress 0f; void Start() { // 获取材质实例非常重要不要直接修改sharedMaterial。 var renderer GetComponentRenderer(); // 对于3D物体 // 对于UI Image: // var image GetComponentImage(); // _material image.material; // 这会创建材质实例 if (renderer ! null) { _material renderer.material; // 创建并获取材质实例 } else { Debug.LogError(No Renderer or Image component found!); } } void Update() { // 平滑地向目标进度过渡 _currentProgress Mathf.Lerp(_currentProgress, progress, Time.deltaTime * animationSpeed); // 更新Shader中的_Progress属性 if (_material ! null) { _material.SetFloat(_Progress, _currentProgress); } } // 提供一个方法供外部调用例如加载完成时 public void SetTargetProgress(float target) { progress Mathf.Clamp01(target); } void OnDestroy() { // 如果是动态创建的材质实例最好销毁以防内存泄漏 if (_material ! null Application.isPlaying) { Destroy(_material); } } }重要提示renderer.material会在运行时动态创建一个该材质的实例对这个实例的修改不会影响Project中的原始材质资产。这是正确的做法。如果使用renderer.sharedMaterial则会修改所有使用该材质的物体通常这不是我们想要的。5.3 常见问题与排查技巧在实际操作中你可能会遇到以下问题问题1圆环不显示或显示不全。检查_InnerRadius和_OuterRadius的值是否合理_InnerRadius _OuterRadius且都小于0.5_Smoothness是否过大导致整个圆环都被平滑掉了调试在Shader中可以先注释掉进度和水波部分只输出ringMask的颜色确保圆环基础绘制正确。问题2进度填充方向不对或起始点不对。调整修改_StartAngle属性。我们代码中设置为-90意味着从顶部12点钟方向开始。如果你想从右边开始设为0从底部开始设为90或-270。理解角度计算依赖于atan2(y, x)其返回值的0度方向是正X轴右。我们的ApplyProgress函数做了归一化和偏移处理。问题3水波效果太强或太弱不像水波。参数联动_WaveFrequency、_WaveSpeed和_WaveAmplitude需要配合调整。一个自然的起步设置可以是Frequency25, Speed3, Amplitude0.015, Sharpness3。尝试多层波使用GenerateMultiLayerWave函数替代单层波效果会立刻丰富起来。添加衰减启用基于距离的衰减让水波在圆环外圈逐渐消失会更自然。问题4在UI上使用Shader后UI元素点击事件失效。原因Unity UI的点击检测依赖于图形的Alpha通道。如果你的Shader输出的Alpha为0即使有颜色UI也不会响应点击。解决确保你的Shader在输出最终颜色时o.Alpha或frag函数返回的颜色的a通道在需要交互的区域大于一个很小的阈值如0.001。在我们的示例中背景色和未填充区域的Alpha也应该是1。问题5性能开销大在移动设备上帧率下降。简化减少水波层数用1-2层降低_WaveFrequency关闭法线扭曲等高级功能。精度确保变量使用了合适的精度fixed/half。合批确保使用相同材质的UI元素可以合批。避免每个进度条都动态创建独立的材质实例如果参数相同应共享材质。如果进度值不同可以考虑使用MaterialPropertyBlock来动态设置属性这样可以在不破坏合批的情况下修改参数。通过以上步骤你应该已经能够创建并驾驭一个美观且性能不错的圆形水波进度动画了。这个Shader就像一个乐高积木你可以自由组合不同的模块——改变颜色渐变方式、替换水波算法、甚至结合噪声纹理创造更有机的形态——来适配你项目中各种不同的场景需求。