FairyGUI扫光特效Shader实现:5分钟打造UI动态高亮质感

FairyGUI扫光特效Shader实现:5分钟打造UI动态高亮质感
1. 项目概述为什么UI动态高亮是提升产品质感的利器在游戏和应用开发中UI的静态呈现早已无法满足用户对视觉体验日益增长的需求。一个按钮、一个图标如果只是静静地躺在那里即便设计再精美也容易显得呆板。而动态效果尤其是那种模拟光线扫过的“扫光”特效能瞬间赋予UI元素生命力吸引用户视线提升界面的科技感和高级感。FairyGUI作为一款强大的UI编辑器其默认的渲染能力足以应对大部分常规需求但当我们想要实现这种定制化的、高性能的动态光效时就需要深入到Shader层面进行“魔改”。这次要实战的“扫光特效”核心目标就是为FairyGUI中的任意图片组件Image注入一道动态划过的高亮光带。它不同于简单的颜色变化或透明度闪烁而是模拟一道有宽度、有渐变、可控制方向和速度的“光束”扫过物体表面的效果。这种效果常用于突出新功能提示、稀有物品边框、可交互按钮的焦点状态或是单纯作为装饰提升视觉冲击力。实现它的关键就在于理解FairyGUI的渲染管线并编写一个能够接收时间参数、进行特定像素计算的片元着色器Fragment Shader。默认情况下FairyGUI使用名为FairyGUI/Image的内置Shader来渲染所有图片组件。你可以把这个Shader理解为一个功能固定、效率极高的“标准加工厂”。它接收图集纹理、顶点颜色等信息然后输出最终的像素颜色。我们要做的不是重建整个工厂而是在这个标准流水线上巧妙地增加一道“激光雕刻”工序。这道工序就是我们的自定义扫光Shader。它需要继承或复制标准Shader的核心结构然后加入我们自己的光效计算逻辑。最终你只需要在FairyGUI编辑器中将某个图片组件的自定义材质指向我们这个新的Shader并动态地修改材质参数如扫光进度、颜色、宽度就能看到流光溢彩的效果。整个过程从Shader编写到在Unity中看到效果顺利的话确实能在5分钟内搭建出原型。2. 核心原理拆解扫光Shader是如何“计算”出光带的要自己动手写一个扫光Shader不能只停留在“复制粘贴代码”的层面必须理解其背后的数学和图形学原理。这样当效果不如预期时你才知道该调整哪个“旋钮”。2.1 光效的数学模型从一维进度到二维遮罩扫光特效的本质是在纹理的特定区域叠加一个额外的高亮颜色。这个区域由一道“光带”定义。我们可以将光带简化理解为一个在特定方向上移动的“窗口”。核心参数扫光进度_ScanLine这是一个在0到1之间变化的值通常由脚本根据时间驱动。0代表光带起点例如左侧1代表光带终点例如右侧。它定义了光带当前的中心位置。光带形状建模我们通常将光带建模为一个在移动方向上的“梯形”或“平滑曲线”区域。最简单的方式是使用线性衰减。假设光带移动方向为水平从左到右对于纹理上的任意一个像素点其UV坐标的U分量即横坐标与当前的_ScanLine值进行比较。计算距离distance abs(uv.x - _ScanLine)。这得到了该像素到光带中心线的绝对距离。定义光带半宽_ScanWidth例如0.1。这意味着光带的总宽度为0.2从中心向两侧各延伸0.1。计算亮度因子strength 1.0 - smoothstep(0.0, _ScanWidth, distance)。smoothstep函数会生成一个在指定阈值之间平滑过渡的值。当distance小于0时strength为1光带中心最亮当distance大于_ScanWidth时strength为0光带外无影响在0到_ScanWidth之间strength平滑地从1衰减到0。这样就得到了一个边缘平滑的光带遮罩。颜色叠加得到了亮度因子strength后我们再定义一个扫光颜色_ScanColor通常是一个亮色如白色或金黄色。最终的像素颜色由原始纹理颜色和扫光颜色混合而成。常用的混合方式是线性插值Lerp或加法Additive。加法混合finalColor texColor _ScanColor * strength * _ScanColor.a。这种方式会使受光区域“变亮”甚至过曝适合发光强烈的效果。屏幕混合Screen这是一种更柔和的变亮方式模拟了多个投影仪将图像投射到同一个屏幕上的效果能保留更多高光细节不易出现色阶断层。在Shader中可以通过1.0 - (1.0 - A) * (1.0 - B)的公式来模拟。注意直接使用加法混合时如果_ScanColor的亮度或strength过高很容易导致颜色值超过1.0即HDR范围在非HDR渲染管线中会被截断为白色可能丢失颜色信息。使用屏幕混合或适当降低强度是更稳妥的做法。2.2 FairyGUI Shader的扩展点与继承关系FairyGUI的默认Shader被打包在内部我们无法直接修改。因此标准做法是创建一个新的Shader通过CGINCLUDE块或直接复制其核心代码并添加我们的属性Properties和片元着色逻辑。定位关键代码你需要找到FairyGUI内置Shader如FairyGUI/Image的源码或通过Shader反编译工具查看其结构。通常它的片元着色器函数名为frag或frag_img会完成纹理采样和顶点颜色混合。创建自定义属性在我们的Shader的Properties块中除了声明主纹理_MainTex外需要新增_ScanColor (Scan Color, Color) (1,1,1,1) _ScanLine (Scan Line Progress, Range(0, 1)) 0 _ScanWidth (Scan Width, Range(0.01, 0.5)) 0.1修改片元着色器在片元着色器函数中先按照原有逻辑采样得到基础颜色col。然后使用上一节所述的公式根据当前像素的UV和_ScanLine、_ScanWidth计算出strength。最后将col与_ScanColor按照strength进行混合输出最终颜色。处理透明与混合模式UI Shader通常使用Blend SrcAlpha OneMinusSrcAlpha进行Alpha混合。我们的扫光计算必须在混合操作之前完成。确保你的颜色计算包含了Alpha通道的正确处理否则会导致边缘出现黑边或透明区域异常。2.3 性能考量在移动设备上也能流畅运行一个看似简单的特效如果编写不当也可能成为性能瓶颈。以下是几个关键优化点计算复杂度我们使用的smoothstep和一次乘法、加法都是GPU极其擅长的简单运算开销几乎可以忽略不计。但要避免在Shader中使用循环、分支if-else判断或纹理查找tex2D等昂贵操作。Overdraw扫光Shader本身不会增加绘制调用Draw Call因为它是在同一个材质球上进行的像素处理。但是如果因为使用特效而将原本可以合批Batching的UI元素拆分开就会导致Draw Call上升。在FairyGUI中尽量确保使用相同自定义材质的组件在渲染顺序上是连续的以利于动态合批。精度选择在片元着色器中对于颜色和UV计算使用half精度中等精度通常就足够了这比float高精度在部分移动GPU上更快。可以声明half4 _ScanColor; half _ScanLine, _ScanWidth;。预处理UV如果扫光方向固定如总是水平可以在顶点着色器中将世界空间或模型空间的坐标转换到一个方便计算的方向向量然后传递给片元着色器避免在片元着色器中进行复杂的坐标变换。3. 完整Shader源码实现与逐行解析理解了原理后我们来看一份可直接用于FairyGUI的、功能完整的扫光Shader源码。我会在关键代码处添加详细注释。// 定义Shader路径在Unity的材质下拉菜单中会显示为 FairyGUI/Image With Scanline Shader FairyGUI/Image With Scanline { Properties { // 主纹理图集这是FairyGUI标准属性 [PerRendererData] _MainTex (Sprite Texture, 2D) white {} // 顶点颜色用于支持UI元素的整体变色和透明度 _Color (Tint, Color) (1,1,1,1) // --- 以下是新增的扫光属性 --- // 扫光颜色RGBA格式。Alpha通道控制扫光颜色的整体强度 _ScanColor (Scan Color, Color) (1, 0.9, 0.7, 1) // 扫光进度0到1通常由脚本控制其变化 _ScanLine (Scan Line Progress, Range(0, 1)) 0 // 扫光带的宽度值越大光带越宽 _ScanWidth (Scan Width, Range(0.01, 0.3)) 0.08 // 扫光边缘硬度值越小边缘越柔和 _ScanSoftness (Scan Softness, Range(0.001, 0.1)) 0.02 } SubShader { // UI Shader标准Tags确保正确的渲染队列和禁用合批由FairyGUI自己管理 Tags { QueueTransparent IgnoreProjectorTrue RenderTypeTransparent PreviewTypePlane CanUseSpriteAtlasTrue } // 标准Alpha混合模式 Blend SrcAlpha OneMinusSrcAlpha Cull Off Lighting Off ZWrite Off Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #pragma multi_compile _ PIXELSNAP_ON #include UnityCG.cginc struct appdata_t { float4 vertex : POSITION; float4 color : COLOR; float2 texcoord : TEXCOORD0; }; struct v2f { float4 vertex : SV_POSITION; fixed4 color : COLOR; float2 texcoord : TEXCOORD0; }; sampler2D _MainTex; float4 _MainTex_ST; // 纹理的缩放偏移值 fixed4 _Color; // 声明新增属性对应的变量 fixed4 _ScanColor; half _ScanLine; half _ScanWidth; half _ScanSoftness; v2f vert(appdata_t IN) { v2f OUT; // 将模型顶点变换到裁剪空间 OUT.vertex UnityObjectToClipPos(IN.vertex); // 传递纹理坐标应用缩放和偏移 OUT.texcoord TRANSFORM_TEX(IN.texcoord, _MainTex); // 传递顶点颜色并与材质整体色调(_Color)相乘 OUT.color IN.color * _Color; #ifdef PIXELSNAP_ON OUT.vertex UnityPixelSnap (OUT.vertex); #endif return OUT; } fixed4 frag(v2f IN) : SV_Target { // 1. 采样主纹理得到基础颜色 fixed4 col tex2D(_MainTex, IN.texcoord) * IN.color; // 2. 计算扫光强度因子 // 假设扫光方向为水平从左到右基于U坐标texcoord.x计算 half distanceToCenter abs(IN.texcoord.x - _ScanLine); // 使用smoothstep创建平滑过渡的遮罩。_ScanSoftness用于控制过渡区的宽度。 // 当distanceToCenter小于(_ScanWidth - _ScanSoftness)时强度为1。 // 当distanceToCenter大于_ScanWidth时强度为0。 // 在中间区域强度平滑过渡。 half scanStrength 1.0 - smoothstep(_ScanWidth - _ScanSoftness, _ScanWidth, distanceToCenter); // 3. 颜色混合使用屏幕混合模式效果更柔和 // 屏幕混合公式1.0 - (1.0 - A) * (1.0 - B) // A是基础颜色B是扫光颜色乘以其强度因子 fixed4 scanContrib _ScanColor * scanStrength * _ScanColor.a; col.rgb 1.0 - (1.0 - col.rgb) * (1.0 - scanContrib.rgb); // Alpha通道处理保持原Alpha扫光不影响物体的整体透明度 // col.a col.a; (保持不变) return col; } ENDCG } } // 如果SubShader不支持回退到标准Sprite Shader Fallback Sprites/Default }关键代码解析与技巧[PerRendererData]属性这个标签告诉Unity_MainTex可能会在每渲染器Per-Renderer的基础上被更改。FairyGUI动态图集系统会为每个UI组件设置不同的纹理区域使用此标签是兼容FairyGUI的标准做法。_ScanSoftness参数这是我额外添加的一个非常实用的参数。原始的smoothstep使用固定的过渡区间。通过引入_ScanSoftness我们可以独立控制光带“核心高亮区”的宽度_ScanWidth - _ScanSoftness和“羽化边缘”的宽度_ScanSoftness。这让光效的调整更加直观和灵活。屏幕混合Screen Blend如代码所示我采用了屏幕混合而非简单的加法。1.0 - (1.0 - A) * (1.0 - B)这个公式能确保混合结果永远不会比输入的颜色更暗且能产生更自然的高光叠加避免加法混合容易导致的颜色过曝和失真。你可以尝试将混合方式改为加法col.rgb scanContrib.rgb;对比一下效果会发现屏幕混合的颜色层次更丰富。Alpha通道处理注意在混合RGB通道后我刻意没有修改col.a。这意味着扫光效果不会改变UI元素本身的透明度。如果你希望扫光区域也影响透明度例如实现一个半透明的光带扫过可以修改col.a但通常不建议因为这可能与UI的交互层级和视觉设计冲突。4. 在Unity与FairyGUI中的完整配置流程有了Shader代码下一步就是将其集成到项目中并驱动起来。4.1 创建材质与FairyGUI组件绑定创建Shader文件在Unity项目的Assets文件夹下例如Assets/Shaders/新建一个文本文件将其后缀改为.shader将上面的代码复制进去。编译与创建材质Unity会自动编译该Shader。编译成功后在Project窗口右键 - Create - Material创建一个新材质球。在材质的Shader下拉列表中选择 “FairyGUI/Image With Scanline”。你将看到我们定义的_ScanColor,_ScanLine等属性出现在材质Inspector面板上。在FairyGUI编辑器中设置自定义材质打开FairyGUI编辑器选择你需要应用扫光效果的图片Image组件。在右侧属性面板的“高级”部分找到“自定义材质”Custom Material选项。将刚才创建的材质球拖拽赋值给它。此时在Unity的Game视图中你可能还看不到效果因为_ScanLine的默认值是0光带可能位于边缘。我们接下来用脚本驱动它。4.2 编写驱动脚本让光带动起来我们需要一个MonoBehaviour脚本来动态修改材质的_ScanLine属性。using UnityEngine; using FairyGUI; // 引入FairyGUI命名空间 public class UIScanlineEffect : MonoBehaviour { // 持有FairyGUI的显示对象GObject public GObject targetUIObject; // 控制参数 public float scanSpeed 0.5f; // 扫光速度每秒移动多少百分比0.5表示0到1需要2秒 public bool pingPong true; // 是否来回扫动 public bool autoStart true; // 是否自动开始 private Material _runtimeMaterial; // 运行时材质实例 private float _currentScanPos 0f; private int _direction 1; // 1表示正向-1表示反向 void Start() { if (targetUIObject null) { Debug.LogError(UIScanlineEffect: 请指定一个FairyGUI对象。); return; } // 获取UI对象关联的渲染器材质 // 注意FairyGUI的每个显示对象都可能有一个包装的Renderer var renderer targetUIObject.displayObject?.gameObject?.GetComponentRenderer(); if (renderer ! null) { // 非常重要必须使用renderer.material来获取材质实例Instance // 而不是renderer.sharedMaterial。直接修改sharedMaterial会影响所有使用该材质的对象。 _runtimeMaterial renderer.material; if (_runtimeMaterial null) { Debug.LogError(UIScanlineEffect: 未能获取到目标对象的材质实例。请检查是否已正确设置自定义材质。); return; } // 初始化扫光位置 _currentScanPos 0f; _runtimeMaterial.SetFloat(_ScanLine, _currentScanPos); if (autoStart) { // 可以在这里启动一个协程或直接在Update中驱动 } } else { Debug.LogError(UIScanlineEffect: 目标对象没有找到Renderer组件。); } } void Update() { if (_runtimeMaterial null) return; // 计算新的扫光位置 _currentScanPos _direction * scanSpeed * Time.deltaTime; // 处理边界 if (pingPong) { if (_currentScanPos 1.0f) { _currentScanPos 1.0f; _direction -1; // 到终点后反向 } else if (_currentScanPos 0.0f) { _currentScanPos 0.0f; _direction 1; // 到起点后正向 } } else { // 非来回模式到达终点后回到起点循环 if (_currentScanPos 1.0f) { _currentScanPos 0.0f; } } // 将新位置传递给Shader _runtimeMaterial.SetFloat(_ScanLine, _currentScanPos); } // 提供外部控制方法 public void StartScan() { if (_runtimeMaterial ! null) { this.enabled true; } } public void StopScan() { this.enabled false; // 可选重置位置 // _currentScanPos 0f; // _runtimeMaterial?.SetFloat(_ScanLine, _currentScanPos); } public void SetScanPosition(float normalizedPosition) { _currentScanPos Mathf.Clamp01(normalizedPosition); _runtimeMaterial?.SetFloat(_ScanLine, _currentScanPos); } }脚本使用要点材质实例Material vs MaterialInstance这是最关键的坑点。在Unity中renderer.sharedMaterial获取的是原始材质资产修改它会影响场景中所有使用该材质的对象。而renderer.material会在第一次访问时自动创建一个该材质的实例Instance修改这个实例只影响当前这个渲染器。对于UI特效我们必须使用实例否则所有用了这个材质的UI都会同步扫光。获取FairyGUI的RendererFairyGUI的显示对象DisplayObject底层对应一个GameObject上面挂载了MeshRenderer等组件。通过targetUIObject.displayObject.gameObject.GetComponentRenderer()可以获取到。性能优化如果场景中有大量UI需要独立控制扫光每个UI都运行一个Update函数并调用SetFloat可能会有开销。可以考虑将驱动逻辑集中到一个管理器或者使用Shader Graph的Time节点如果使用Shader Graph编写在GPU端驱动。但对于几十上百个元素当前方式在移动端也是完全可以承受的。参数动态调节你可以在脚本中暴露更多参数如_ScanWidth,_ScanColor并在运行时通过SetFloat,SetColor方法动态修改实现更复杂的效果如光带宽度随时间变化。4.3 进阶实现任意方向扫光上面的Shader只实现了水平方向扫光。如何实现任意角度扫光关键在于将UV坐标进行旋转投影。在Shader中添加角度属性_ScanAngle (Scan Angle (Degrees), Range(0, 360)) 0 // 角度0度为从左到右在片元着色器中计算投影距离// 将角度转换为弧度 half angleRad _ScanAngle * UNITY_PI / 180.0; // 计算旋转后的投影轴方向向量 half2 scanDir half2(cos(angleRad), sin(angleRad)); // 将UV坐标中心移到(0.5, 0.5)方便旋转计算 half2 centeredUV IN.texcoord - half2(0.5, 0.5); // 计算当前像素在扫光方向轴上的投影坐标点积 half projectedPos dot(centeredUV, scanDir); // 将投影坐标从[-0.5*sqrt(2), 0.5*sqrt(2)]范围重新映射到[0,1]范围作为新的“进度”参考 half normalizedProjection (projectedPos 0.707) / 1.414; // 0.707 ≈ 0.5*sqrt(2) // 使用映射后的坐标与_ScanLine进行比较 half distanceToCenter abs(normalizedProjection - _ScanLine);这样通过调整_ScanAngle你就可以让光带沿着任意方向扫过了。注意这里的重新映射是一个近似对于非正方形的UI元素边缘处可能会有轻微变形但对于大多数UI效果来说已经足够好。5. 实战避坑指南与效果调优在实际应用过程中你可能会遇到一些预期之外的情况。这里记录了几个常见问题和我的解决方案。5.1 效果不显示或显示异常问题现象可能原因排查步骤与解决方案完全看不到扫光效果1. 材质未正确应用。2. Shader编译错误。3._ScanLine值始终为0或1光带在视野外。4._ScanWidth值太小。1. 在Unity编辑器中选中UI对象检查其MeshRenderer的Material是否是你创建的自定义材质实例。2. 在Console窗口查看是否有Shader编译错误粉色错误信息。3. 在脚本中打印或通过材质面板手动调整_ScanLine值看是否有变化。4. 尝试将_ScanWidth调大到0.3确保光带足够宽。扫光颜色异常如全黑、全白1. 颜色混合公式错误。2._ScanColor的Alpha为0。3. 使用了错误的混合模式Blend。1. 检查片元着色器中的混合计算代码。临时将scanStrength固定为1看_ScanColor是否正确显示。2. 确保材质面板上_ScanColor的A值大于0。3. 确保SubShader中的Blend模式是SrcAlpha OneMinusSrcAlpha。扫光边缘有锯齿Aliasing1._ScanSoftness值太小或为0。2. UI图片本身分辨率过低。1. 适当增加_ScanSoftness的值如0.05让过渡更平滑。2. 这是Shader基于像素计算的固有局限。可以考虑使用fwidth函数进行基于屏幕空间的抗锯齿但会稍增加开销或者确保UI素材有足够的分辨率。扫光导致UI其他部分变暗颜色混合模式使用不当如乘法。确认你使用的是屏幕混合Screen或加法混合Additive这两种都是“变亮”型混合。避免使用乘法Multiply等变暗的混合。动态修改材质参数无效错误地修改了sharedMaterial而非material。在驱动脚本中务必使用renderer.material来获取和设置参数。可以在Awake或Start中获取一次并缓存。5.2 性能与最佳实践合批破坏者在FairyGUI中使用相同材质、且渲染顺序连续的UI元素会被动态合批减少Draw Call。如果你给大量UI元素赋予了不同的材质实例即使Shader相同它们将无法合批。一个优化策略是对于不需要独立控制扫光进度、只是同步播放相同效果的多个UI可以让它们共享同一个材质实例。只需在一个地方如管理器更新材质参数所有使用该材质的UI会同步变化。控制更新频率不是所有扫光都需要每帧更新。例如一个缓慢的、装饰性的背景流光可以用Time.deltaTime * 0.2来降低速度甚至每几帧更新一次位置。使用Shader.PropertyToID在脚本中频繁调用SetFloat(“_ScanLine”, value)时字符串查找“_ScanLine”有微小开销。可以在脚本开头声明静态只读的整数IDprivate static readonly int ScanLineID Shader.PropertyToID(_ScanLine); // 使用时 _runtimeMaterial.SetFloat(ScanLineID, _currentScanPos);这对于需要高频更新的特效如战斗UI有优化意义。在移动端的测试在真机上测试效果。过于复杂的光效如多层叠加、每帧计算大量三角函数在低端设备上可能成为瓶颈。本文提供的Shader计算量极低通常不会成为性能问题。5.3 创意扩展思路掌握了基础扫光后你可以尝试组合出更酷的效果双向扫光使用两个_ScanLine和_ScanColor让两道不同颜色或速度的光带相对而行或相向而行。纹理化光带不再使用纯色_ScanColor而是采样另一张噪声纹理或渐变纹理用scanStrength作为UV的X或Y坐标让光带内部带有纹理细节。扭曲效果在计算扫光强度的同时用scanStrength去扰动主纹理_MainTex的采样UV可以让光带扫过的地方产生热浪扭曲般的变形效果。与UI动画结合不要只让脚本驱动_ScanLine。可以将_ScanLine与FairyGUI的自定义动画系统关联利用动画曲线AnimationCurve来控制扫光的速度和节奏实现更富动感的效果。我个人在多个项目中使用这个方案的体会是它的性价比极高。编写一次Shader创建一种材质就能通过脚本参数无限复用和变化。它比制作序列帧动画节省大量的纹理内存比使用粒子系统更易于集成到UI层级管理中。最关键的是它给了设计和策划同学一个直观的“参数面板”——颜色、宽度、速度、角度他们可以通过调节这些数值而无需程序员介入就能快速调出想要的感觉这对于提升团队协作效率和效果迭代速度非常有帮助。

最新新闻

日新闻

周新闻

月新闻