
1. 项目概述从零到一理解扫光效果的本质扫光效果在游戏和影视UI中太常见了。你肯定见过那种一道亮光沿着图标边缘或文字表面快速掠过的炫酷效果它能在瞬间抓住玩家的眼球提升界面的质感和动态反馈。对于很多刚接触Unity和Shader的开发者来说这种效果听起来很“高级”似乎需要深厚的图形学功底。但今天我要告诉你它的核心原理其实非常直观甚至可以说是一种“视觉魔术”只要理解了几个关键概念你完全可以在5分钟内搭出基础效果再花点时间掌握旋转调节就能玩出各种花样。简单来说扫光效果就是一个高亮的“光带”在物体表面沿着特定方向移动。这个“光带”通常不是真的光源而是通过Shader计算让模型表面某个特定区域的像素变得更亮、颜色更饱和甚至带有一些辉光。实现它的核心思路是利用一个随时间变化的“遮罩”来控制哪里该亮哪里保持原样。这个遮罩可以想象成一张从黑到白的渐变图白色区域代表“被光照亮”黑色区域代表“保持原貌”而让这张图动起来扫光就产生了。这个项目非常适合Shader小白入门因为它不涉及复杂的光照模型如PBR或艰深的数学。我们将从最直观的ShaderGraph可视化工具入手让你“画”出这个效果理解数据是如何流动的然后我们也会深入到代码层面看看手写Shader是如何实现同样逻辑的这能帮你建立更底层的认知。无论你是UI设计师想自己实现动效还是程序想丰富自己的技能树掌握这个效果都是一个绝佳的起点。2. 核心思路拆解如何用数据“画”出一道移动的光在动手之前我们必须把脑子里那个模糊的“一道光扫过去”的概念翻译成Shader能理解的精确数据流。这个过程是理解所有后续操作的基础。2.1 核心算法基于UV坐标的线性插值扫光效果最经典、最易理解的实现方式是基于模型的UV坐标。UV坐标你可以理解为模型表面的“身份证”它告诉Shader每个像素点在纹理贴图上的对应位置。通常UV的U水平和V垂直坐标范围都在0到1之间。扫光遮罩的生成我们的目标是创建一个值在0到1之间变化的“遮罩”。一个简单的办法是用一个线性变化的值去和UV的某个方向比如U方向进行比较。例如我们定义一个变量_ScanPosition它的值从0线性增加到1。对于模型表面的每个像素我们取出它的U坐标然后计算mask step(_ScanPosition - _ScanWidth, U) * step(U, _ScanPosition _ScanWidth)。这里的_ScanWidth控制了光带的宽度。这个公式的意思是只有当像素的U坐标落在[_ScanPosition - _ScanWidth, _ScanPosition _ScanWidth]这个区间内时mask的值才为1白色否则为0黑色。让_ScanPosition随时间变化这个白色区域光带就会移动。更平滑的边缘上面的方法产生的是硬边缘的光带看起来比较生硬。更常用的方法是使用smoothstep函数。smoothstep会生成一个平滑的过渡。公式可以变为mask smoothstep(_ScanPosition - _ScanWidth, _ScanPosition, U) - smoothstep(_ScanPosition, _ScanPosition _ScanWidth, U)。这样生成的光带中心最亮向两侧平滑衰减视觉效果更加柔和、真实。注意这里选择U方向还是V方向决定了光带默认的移动方向。这是后续实现旋转调节的基础。默认沿U轴水平移动是最简单的理解方式。2.2 旋转调节的数学本质坐标变换如果我们只满足于水平或垂直的扫光那上面的思路就够了。但想要光带能沿着任意角度扫描就必须引入旋转。这在数学上是一个二维坐标旋转问题。我们之前是直接用模型的原始UV坐标比如U来和_ScanPosition比较。为了实现旋转我们需要将原始UV坐标进行旋转变换得到一个新的坐标值然后用这个新坐标的某个分量比如新的X分量来生成遮罩。具体公式如下假设旋转中心为UV坐标(0.5, 0.5)即中心将UV坐标平移到以(0.5, 0.5)为中心uv_centered uv - 0.5应用旋转矩阵假设旋转角度为_Angle弧度制rotatedU uv_centered.x * cos(_Angle) - uv_centered.y * sin(_Angle)rotatedV uv_centered.x * sin(_Angle) uv_centered.y * cos(_Angle)此时rotatedU就是我们需要的、沿着旋转后方向的新坐标。我们用rotatedU替代之前公式中的U来生成遮罩。这样当_ScanPosition变化时光带就会沿着我们设定的_Angle方向移动。理解了这个数学本质无论是在ShaderGraph里连接节点还是在代码里写公式你都会胸有成竹知道每一个步骤的目的。3. 使用ShaderGraph可视化实现对于初学者和追求开发效率的团队来说ShaderGraph是神器。它让我们能通过“连连看”的方式构建Shader直观地看到数据流。我们先用它快速实现基础效果。3.1 创建Graph与基础节点搭建首先在Unity中创建一个Unlit Shader Graph因为扫光通常是自己发光的效果不受场景光照影响。打开Graph后我们需要以下核心节点Time节点提供持续增加的时间值它是驱动动画的源头。Multiply与Add节点用Time乘以一个速度系数_ScanSpeed再加上一个起始偏移_ScanOffset得到我们核心的_ScanPosition变量。公式ScanPosition frac(Time * _ScanSpeed _ScanOffset)。这里用frac函数取小数部分是为了让ScanPosition在0到1之间循环。UV节点获取模型的基础UV坐标。Rotate节点或自定义旋转计算节点组这是实现旋转的关键。ShaderGraph内置了“Rotate”节点你可以直接连接UV和角度参数。如果没有就需要按照上一节讲的公式用Arctangent2,Sine,Cosine,Multiply,Add等节点自己组装一个旋转计算网络。Split节点将旋转后的UV的R和G通道即U和V分开。Smoothstep节点需要两个。我们用旋转后的U坐标假设是R通道与(ScanPosition - ScanWidth)和ScanPosition做第一个Smoothstep再与ScanPosition和(ScanPosition ScanWidth)做第二个Smoothstep然后将两者相减得到平滑的遮罩Mask。Sample Texture 2D节点采样物体原本的主纹理。Lerp节点线性插值这是最终合成的核心。它的A端口输入原始纹理颜色B端口输入扫光的高亮颜色比如白色或金色T端口输入我们计算好的Mask。Lerp会根据Mask的值0到1在原始色和高亮色之间进行插值混合。Mask为0的地方输出原色为1的地方输出高亮色中间值就是平滑过渡。3.2 参数化与效果调节将上述计算中的关键数值都暴露为Graph的Property属性这样我们就可以在材质球面板上实时调节而无需修改Shader代码_ScanSpeed扫光移动速度。_ScanWidth光带的宽度。_ScanColor扫光的高亮颜色。_ScanIntensity扫光强度。通常将Mask乘以这个强度系数后再输入给Lerp的T端口可以控制扫光的亮度。_RotationAngle旋转角度单位可以是度在节点内部转换为弧度。搭建完毕后将这个Graph保存为一个Shader创建一个材质球使用它并赋给一个Quad或你的UI图片。在Scene视图或Game视图里你应该能看到一道光平滑地扫过物体表面。拖动材质面板上的滑块实时观察速度、宽度、颜色和角度的变化这是理解每个参数作用的最好方式。实操心得在ShaderGraph中调试时可以经常使用“Preview”节点将中间计算的结果如Mask直接连接到主输出的“Color”上这样可以单独预览遮罩的形状和运动是否正确非常利于排查问题。4. 手写Shader代码实现虽然ShaderGraph很方便但了解手写代码的实现能让你更深入地控制细节并且在一些对性能要求苛刻或者需要复杂变体的场合代码方案更灵活。我们基于Unity的URP通用渲染管线来编写一个简单的Unlit Shader。4.1 Shader属性与变量声明首先在Shader的Properties块中声明所有需要在材质面板调节的参数。Properties { _MainTex (Main Texture, 2D) white {} _BaseColor (Base Color, Color) (1,1,1,1) // 扫光参数 _ScanColor (Scan Color, Color) (1,0.9,0.6,1) // 通常用暖色调 _ScanSpeed (Scan Speed, Float) 1.0 _ScanWidth (Scan Width, Range(0, 0.5)) 0.1 _ScanIntensity (Scan Intensity, Range(0, 5)) 2.0 _RotationAngle (Rotation Angle (Degrees), Float) 0.0 }在CGPROGRAM代码块中需要定义对应的变量来接收这些属性。sampler2D _MainTex; float4 _MainTex_ST; // 用于纹理的缩放和偏移 half4 _BaseColor; half4 _ScanColor; float _ScanSpeed; float _ScanWidth; float _ScanIntensity; float _RotationAngle;4.2 顶点与片元着色器核心逻辑顶点着色器vert主要负责完成标准的坐标变换和传递UV等数据。我们重点看片元着色器frag所有扫光计算都在这里进行。// 顶点着色器输出结构体 struct Attributes { float4 positionOS : POSITION; float2 uv : TEXCOORD0; }; struct Varyings { float4 positionHCS : SV_POSITION; float2 uv : TEXCOORD0; }; Varyings vert(Attributes IN) { Varyings OUT; OUT.positionHCS TransformObjectToHClip(IN.positionOS.xyz); OUT.uv TRANSFORM_TEX(IN.uv, _MainTex); // 应用纹理的缩放和偏移 return OUT; } // 片元着色器 half4 frag(Varyings IN) : SV_Target { // 1. 采样主纹理 half4 mainTexColor tex2D(_MainTex, IN.uv) * _BaseColor; // 2. 将UV坐标中心化以(0.5, 0.5)为中心 float2 centeredUV IN.uv - 0.5; // 3. 将角度转换为弧度并计算正弦余弦值 float rad _RotationAngle * 0.0174532925; // 度转弧度PI/180 float cosA cos(rad); float sinA sin(rad); // 4. 应用旋转矩阵得到旋转后的U坐标我们只关心用于扫描的方向 float rotatedU centeredUV.x * cosA - centeredUV.y * sinA; // 5. 将旋转后的坐标范围从[-0.5, 0.5]映射回[0, 1]方便与ScanPosition比较 // 也可以不映射直接调整ScanPosition的范围这里映射更直观 float scanCoord rotatedU 0.5; // 6. 计算动态的扫描位置循环 float scanPos frac(_Time.y * _ScanSpeed); // 7. 使用smoothstep生成平滑的遮罩 float falloff _ScanWidth * 0.5; // 让宽度定义更直观表示光带半宽 float mask smoothstep(scanPos - falloff, scanPos, scanCoord) - smoothstep(scanPos, scanPos falloff, scanCoord); // 8. 增强遮罩效果可选使光带中心更锐利 mask pow(mask, _ScanIntensity); // 9. 混合颜色根据遮罩值在原始颜色和扫光颜色之间插值 half4 finalColor lerp(mainTexColor, _ScanColor, mask); // 另一种混合方式叠加模式更亮 // finalColor mainTexColor _ScanColor * mask; return finalColor; }这段代码清晰地展示了从UV变换、旋转计算、动态遮罩生成到最终颜色混合的完整流程。注释解释了每一步的意图。你可以将这段代码复制到一个新的Shader文件中进行测试。4.3 代码实现的优势与细节控制相比于ShaderGraph手写代码在一些细节上控制更精准性能微调你可以精确控制计算精度如使用half代替float或者根据平台特性进行优化。复杂变体比如实现一条光带扫过后留下一条渐隐的“尾迹”这在代码中只需额外维护一个时间变量和衰减计算即可在Graph中可能连线会非常复杂。数学优化例如你可以预计算旋转角度的正弦余弦值避免在片元着色器中对每个像素都计算sin和cos虽然现代GPU压力不大。可以通过在顶点着色器计算并传递给片元着色器或者使用uniform变量。注意事项在片元着色器中频繁进行sin,cos计算和矩阵乘法理论上会有性能开销。但对于一个简单的UI效果或非大量实例化的物体这点开销完全可以接受。如果需要在大量物体上使用应考虑将旋转计算移至顶点着色器或者使用更简化的近似方法。5. 高级技巧与效果扩展掌握了基础扫光后我们可以让它变得更酷、更实用。5.1 多段扫光与随机化一道光可能有些单调。我们可以同时计算多个ScanPosition。// 在片元着色器中 float mask 0; int scanCount 3; // 3道光 for (int i 0; i scanCount; i) { // 为每一道光设置不同的相位偏移 float phaseOffset (float)i / (float)scanCount; float scanPos frac(_Time.y * _ScanSpeed phaseOffset); // 也可以为每一道光设置略微不同的宽度或颜色 float individualMask smoothstep(scanPos - _ScanWidth, scanPos, scanCoord) - smoothstep(scanPos, scanPos _ScanWidth, scanCoord); mask individualMask; } mask saturate(mask); // 确保值在0-1之间更进一步可以利用噪声纹理或者伪随机函数让每道光的速度、宽度甚至颜色都有细微的随机变化创造出更自然、不机械的流光效果。5.2 结合纹理遮罩与形状控制我们目前的光带是规则的矩形过渡。但有时我们希望扫光只出现在物体的特定部分或者光带本身有特殊的形状如波浪形、锯齿形。纹理遮罩额外采样一张灰度贴图Mask Texture。在计算最终mask后与这张遮罩纹理的灰度值相乘。这样白色区域扫光正常显示黑色区域则没有扫光。你可以用这张图来控制扫光只出现在Logo的金属部分而不影响背景。形状控制影响scanCoord。在将rotatedU用于比较之前可以对其施加一个函数变换。例如scanCoord (rotatedU 0.5) sin(rotatedU * 10 _Time.y) * 0.05;这会制造一个波浪形移动的光带。使用一张噪声纹理来扰动scanCoord可以让光带边缘产生不规则的辉光粒子感。5.3 性能优化要点虽然扫光Shader不重但良好的习惯能让你的项目更稳健。精度选择对于颜色和UV插值使用half精度中等精度浮点数通常足够且在某些移动平台上更快。对于时间、角度等变量使用float。计算转移将_RotationAngle转为弧度、计算sinA和cosA的操作应该放在片元着色器外部。理想情况下在CPU端计算好通过材质属性Material.SetFloat传递进来。或者在Shader中如果角度是常量这些计算也只需执行一次。我们可以通过一个自定义函数或者利用Unity的[MaterialPropertyDrawer]在材质面板改变角度时自动触发计算但这需要编辑器脚本支持。避免分支GPU不喜欢if语句。我们的实现中使用了smoothstep和lerp这些都是内置的、GPU友好的函数没有分支是很好的实践。针对UI的优化如果专门用于UI考虑使用Unity UI的MaskableGraphic组件配合自定义材质并确保Shader支持UI的裁剪和混合设置。UI Draw Call合并对性能至关重要复杂的Shader可能会打断合批。6. 实战问题排查与调试心得在实际操作中你肯定会遇到效果不如预期的情况。这里记录几个我踩过的坑和解决方法。6.1 常见问题速查表问题现象可能原因排查与解决思路扫光完全不显示1. Shader编译错误。2._ScanWidth设置为0。3._ScanColor的Alpha值为0。4. 混合模式错误扫光部分被覆盖。1. 查看Console窗口是否有Shader错误红色报错。2. 检查材质面板参数确保_ScanWidth 0。3. 检查_ScanColor的A通道值。4. 对于UI检查Canvas的渲染顺序和材质渲染队列。尝试将Shader的混合模式设为Blend SrcAlpha OneMinusSrcAlpha普通Alpha混合。扫光位置不对或不动1. UV坐标使用错误可能用了世界空间坐标。2._ScanSpeed为0。3._Time变量使用错误在URP中可能是_Time.y 在Built-in中可能是_Time.x。4. 旋转计算错误sin和cos用反。1. 在片元着色器开始直接返回float4(IN.uv.x, IN.uv.y, 0, 1)来可视化UV检查是否正确。2. 检查_ScanSpeed参数。3. 查阅对应渲染管线的Shader内置变量文档。4. 将旋转角度设为0先确认基础扫光正常。然后逐步增加角度检查运动方向是否符合预期可使用一个箭头纹理辅助观察。扫光边缘有锯齿Aliasing光带边缘过渡太硬smoothstep的过渡区间太小或者屏幕分辨率较低。1. 适当增加_ScanWidth让过渡区域更宽。2. 在smoothstep计算后对mask进行一次轻微的模糊处理例如采样相邻像素取平均。3. 确保项目设置了合适的抗锯齿MSAA或FXAA。在模型弯曲处扫光变形使用了模型自带的UV而该UV在模型表面是不均匀或拉伸的。1. 对于复杂模型考虑使用屏幕空间坐标或世界空间坐标的某一分量来驱动扫光而不是模型UV。这能保证光带在屏幕上是平直的。2. 如果需要跟随模型形状可能需要使用三平面映射Triplanar Mapping等技术来生成更一致的坐标。移动设备上发热或帧率下降Shader计算复杂度偏高或在大量物体上使用。1. 遵循第5.3节的性能优化建议。2. 减少smoothstep的计算次数或使用查找表LUT近似。3. 考虑是否真的需要每帧更新。对于菜单UI扫光可以只在显示时播放一次而非循环播放。6.2 调试技巧可视化中间数据这是Shader调试中最强大的技巧。当你不知道哪里出错时把中间的计算结果直接输出为颜色。调试遮罩在片元着色器最后直接return float4(mask, mask, mask, 1);。你会看到一张黑白图白色区域就是扫光生效的区域。检查它的形状、位置和运动是否符合预期。调试旋转坐标return float4(scanCoord, scanCoord, scanCoord, 1);。你会看到一个从黑到白的渐变图。随着物体旋转这个渐变图的方向应该改变。如果不变说明旋转计算没生效。调试UVreturn float4(IN.uv.x, IN.uv.y, 0, 1);。这是检查UV是否正确的标准方法红色代表U绿色代表V。6.3 与动画系统结合我们一直用_Time驱动自动循环扫光。但在实际游戏中扫光往往是由事件触发的如获得新物品、任务提示。这时更好的做法是通过脚本控制一个_ScanProgress参数0到1而不是直接依赖_Time。在C#脚本中Material targetMaterial; float scanDuration 1.0f; float currentScanTime 0f; bool isScanning false; void Update() { if (isScanning) { currentScanTime Time.deltaTime; float progress Mathf.Clamp01(currentScanTime / scanDuration); targetMaterial.SetFloat(_ScanProgress, progress); if (currentScanTime scanDuration) { isScanning false; // 可选重置或隐藏扫光 // targetMaterial.SetFloat(_ScanProgress, 0); } } } public void StartScanEffect() { isScanning true; currentScanTime 0f; }在Shader中将scanPos frac(_Time.y * _ScanSpeed);替换为scanPos _ScanProgress;。这样你就可以用动画曲线、事件来精确控制扫光的启动、速度、循环次数了。我个人在实际使用中发现把扫光效果和UI的交互反馈结合起来特别出彩。比如一个按钮在可点击时赋予它一个缓慢的、低强度的扫光作为呼吸效果当鼠标悬停时扫光速度加快强度变高点击的瞬间可以触发一道快速的强光扫过。这种动态的、与状态联动的视觉效果比静态的图片或纯色变化要生动得多。关键在于控制好节奏和强度避免过度炫技导致视觉疲劳。多观察优秀游戏中的UI动效拆解它们的节奏和触发逻辑是提升自己设计能力的最好方法。