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 { "Queue"="Transparent" "IgnoreProjector"="True" "RenderType"="Transparent" "PreviewType"="Plane" "CanUseSpriteAtlas"="True" } // 标准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?.GetComponent<Renderer>(); 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的Renderer:FairyGUI的显示对象(DisplayObject)底层对应一个GameObject,上面挂载了MeshRenderer等组件。通过
targetUIObject.displayObject.gameObject.GetComponent<Renderer>()可以获取到。 - 性能优化:如果场景中有大量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。 |
| 扫光边缘有锯齿(Aliasing) | 1._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”有微小开销。可以在脚本开头声明静态只读的整数ID:
这对于需要高频更新的特效(如战斗UI)有优化意义。private static readonly int ScanLineID = Shader.PropertyToID("_ScanLine"); // 使用时 _runtimeMaterial.SetFloat(ScanLineID, _currentScanPos); - 在移动端的测试:在真机上测试效果。过于复杂的光效(如多层叠加、每帧计算大量三角函数)在低端设备上可能成为瓶颈。本文提供的Shader计算量极低,通常不会成为性能问题。
5.3 创意扩展思路
掌握了基础扫光后,你可以尝试组合出更酷的效果:
- 双向扫光:使用两个
_ScanLine和_ScanColor,让两道不同颜色或速度的光带相对而行或相向而行。 - 纹理化光带:不再使用纯色
_ScanColor,而是采样另一张噪声纹理或渐变纹理,用scanStrength作为UV的X或Y坐标,让光带内部带有纹理细节。 - 扭曲效果:在计算扫光强度的同时,用
scanStrength去扰动主纹理_MainTex的采样UV,可以让光带扫过的地方产生热浪扭曲般的变形效果。 - 与UI动画结合:不要只让脚本驱动
_ScanLine。可以将_ScanLine与FairyGUI的自定义动画系统关联,利用动画曲线(AnimationCurve)来控制扫光的速度和节奏,实现更富动感的效果。
我个人在多个项目中使用这个方案的体会是,它的性价比极高。编写一次Shader,创建一种材质,就能通过脚本参数无限复用和变化。它比制作序列帧动画节省大量的纹理内存,比使用粒子系统更易于集成到UI层级管理中。最关键的是,它给了设计和策划同学一个直观的“参数面板”——颜色、宽度、速度、角度,他们可以通过调节这些数值而无需程序员介入,就能快速调出想要的感觉,这对于提升团队协作效率和效果迭代速度非常有帮助。