做2D游戏的朋友应该都有这种感受:某张卡面、道具图标、角色立绘单独摆在那没问题,但一旦放进战斗场景或者商店界面,静止的图片时间长了就是发闷。给图片加一个扫光效果,画面立刻“活”过来,这也是抽卡、结算、商店刷新里最常用的一种提气手法。不过很多人一搜扫光,找到的大多是针对UI Image写的Shader,或者Shader Graph教程,搬到Sprite Renderer上要么直接变紫、要么光带横竖不对。这篇文章就把Sprite Renderer条件下怎么快速实现扫光讲透,从一段能用的Shader开始,再到图集适配、多对象性能控制,最后附上我实际踩过的坑。
适合谁看:Unity新手、独立开发者,以及需要给2D项目快速加特效的程序员和美术。不需要你有多少Shader基础,我先把可以直接抄的代码给你,再解释每行在干什么,最后告诉你为什么要这么写。
1. 扫光效果的原理拆解:为什么一道“光带”会有高级感
1.1 视觉本质与数学基础
扫光效果的视觉本质很简单:有一道光带,沿着某个方向周期性地从图片上扫过去,光带扫过的区域颜色变亮,扫完再回到起点,循环往复。从技术实现角度看,这个问题可以拆成三个小问题:
- 怎么判断“某个像素有没有被光带覆盖到”?
- 怎么让光带的边缘是柔和的,而不是一条生硬的白线?
- 怎么让光带自己动起来?
第一个问题的标准做法是“投影距离”。你可以把UV坐标想象成一张地图,地图上有X和Y两个轴。我们定义一个扫光方向向量,比如(1, 0.3),然后把每一个像素的UV坐标往这个方向上做投影,得到的投影值就代表这个像素“在扫光路径上的位置”。再定义一个随时间移动的光带中心点,计算每个像素的投影值与中心点的距离,距离小于光带宽度,就说明这个像素被光带命中。
第二个问题靠smoothstep解决。很多人第一版实现直接用if判断距离,结果扫出来一条边缘锯齿感十足的硬光。正确做法是用一个平滑过渡函数,让距离从0到光带宽度之间连续衰减,这样光带边缘就是渐变柔和的效果,视觉上才像真实的光。
第三个问题更简单,用Unity内置的_Time.y乘上一个速度值,再取小数部分,就能得到一个0到1循环的周期量,把这个周期量映射到光带中心的位置上,光带就动起来了。
这三个点合在一起,就是扫光效果的核心数学基础。
1.2 为什么要在Shader里做而不是用动画
刚接触这个问题时,我一度觉得“这不就是叠一个会移动的白色半透明条带吗,做成子物体用DOTween移动不就行了”。确实可以,但实际做出来你会发现两个问题:
第一,额外增加了一个精灵条带和Mask遮罩,多出DrawCall,如果同时有十几个道具图标在扫光,性能直接吃紧。第二,条带是矩形,遇到镂空的角色剪影、不规则卡面时,光带会从透明区域穿帮,看起来像一块白色塑料板在图片表面滑过,非常廉价。
Shader方案完全没有这两个问题。扫光计算发生在像素着色阶段,光带只对纹理中不透明的像素生效,自动贴合精灵本身的形状,镂空区域不会漏光,而且零额外DrawCall。这就像在照片表面镀了一层会动的光膜,而不是在照片上方悬浮一张透明纸条。两者观感差距,做出来对比一次就再也不想回去了。
2. 最小可用实现:一段Shader跑通Sprite扫光
2.1 从Sprite默认Shader开始改
Sprite Renderer渲染时用的默认Shader是Sprites/Default,它本身做了透明混合、Cull Off、ZWrite Off这些基础设置。我们自定义的扫光Shader,最好的起点就是在这些设置上做加法,而不是另起炉灶,否则很容易遇到半透明排序错乱、图集不兼容、精灵变黑等怪问题。
SubShader里的关键Tag和状态,我建议直接照抄Sprites/Default:
Tags { "Queue"="Transparent" "RenderType"="Transparent" "PreviewType"="Plane" "CanUseSpriteAtlas"="True" } Cull Off Lighting Off ZWrite Off Blend One OneMinusSrcAlpha这些设置的含义:透明队列保证和UI、其他半透明物体正确排序;Cull Off保证Sprite无论怎么旋转都不背脸;Blend One OneMinusSrcAlpha是Sprite家族惯用的预乘Alpha混合模式,配合SpriteRender的顶点色输出,颜色不会出现黑边。
2.2 核心计算:投影距离与平滑边界
片段着色器里,主纹理采样和Sprites/Default保持一致,关键是采样之后叠加扫光计算。代码的核心是这三行:
float2 dir = normalize(float2(1.0, _ShineAngle)); float center = (frac(_Time.y * _ShineSpeed) - 0.5) * 1.6; float proj = dot(localUV - 0.5, dir); float dist = abs(proj - center);先把localUV平移到以0.5为中心,然后往dir方向投影。center在负0.8到正0.8之间循环移动,对应的就是光带从图片一端扫到另一端。_ShineAngle控制倾斜度,正值向左上扫,负值向右下扫,具体哪个方向看你的UV坐标系和审美需求。
得到距离后,用smoothstep把距离映射成光强度:
float shine = 1.0 - smoothstep(0.0, _ShineWidth, dist); shine = pow(shine, _Gloss);2.3 完整Shader代码
下面这段Shader是完整可用的,Built-in渲染管线直接创建材质、拖给SpriteRenderer即可。我注释写得比较细,可以直接复制使用:
Shader "Custom/SpriteShine" { Properties { [PerRendererData] _MainTex ("Sprite Texture", 2D) = "white" {} _ShineColor ("Shine Color", Color) = (1, 1, 1, 0.6) _ShineSpeed ("Shine Speed", Range(0, 5)) = 0.8 _ShineWidth ("Shine Width", Range(0.01, 0.5)) = 0.12 _ShineAngle ("Shine Angle", Range(-1, 1)) = 0.3 _Gloss ("Gloss", Range(1, 8)) = 3 _SpriteRect ("Sprite Rect (Normalized)", Vector) = (0, 0, 1, 1) } SubShader { Tags { "Queue"="Transparent" "RenderType"="Transparent" "PreviewType"="Plane" "CanUseSpriteAtlas"="True" } Cull Off Lighting Off ZWrite Off Blend One OneMinusSrcAlpha Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #pragma target 2.0 #include "UnityCG.cginc" struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; fixed4 color : COLOR; }; struct v2f { float4 vertex : SV_POSITION; float2 uv : TEXCOORD0; fixed4 color : COLOR; }; sampler2D _MainTex; fixed4 _ShineColor; float _ShineSpeed; float _ShineWidth; float _ShineAngle; float _Gloss; float4 _SpriteRect; v2f vert (appdata v) { v2f o; o.vertex = UnityObjectToClipPos(v.vertex); o.uv = v.uv; o.color = v.color; return o; } fixed4 frag (v2f i) : SV_Target { // 把图集UV还原成精灵局部0~1坐标,非图集时该操作等于没变化 float2 localUV = (i.uv - _SpriteRect.xy) / _SpriteRect.zw; // 标准Sprite采样,保留顶点色 fixed4 c = tex2D(_MainTex, i.uv) * i.color; // 扫光方向向量,_ShineAngle控制倾斜 float2 dir = normalize(float2(1.0, _ShineAngle)); // 光带中心周期移动,范围约 -0.8 到 0.8 float center = (frac(_Time.y * _ShineSpeed) - 0.5) * 1.6; // 像素在扫光方向上的投影位置 float proj = dot(localUV - 0.5, dir); // 距离光带中心越近越亮 float dist = abs(proj - center); float shine = 1.0 - smoothstep(0.0, _ShineWidth, dist); shine = pow(shine, _Gloss); // 只在不透明区域叠加光,避免镂空部分也发光 c.rgb += _ShineColor.rgb * shine * c.a * _ShineColor.a; return c; } ENDCG } } Fallback "Sprites/Default" }这段代码里有一个容易被忽略但是很关键的细节:c.rgb += _ShineColor.rgb * shine * c.a。乘上c.a的目的,是让光带只在精灵不透明的像素上显现。如果不乘这个Alpha,扫光扫过镂空区域时,光带会直接盖在透明像素上,产生明显的“条带穿帮感”。
2.4 使用步骤与参数调节
材质创建和使用就三步:
- 在Project窗口右键 -> Create -> Material,命名随意。
- Shader下拉框里选择
Custom/SpriteShine。 - 把材质拖到SpriteRenderer的Material属性上,或者运行时用代码赋值。
调参阶段我建议先把_ShineSpeed调到0.2,让光带慢速扫过,这样能清楚看到光带的宽度、角度、柔和度是否合适。确认形态后再把速度调回正常值。_ShineWidth是光带的宽度,注意它的单位不是像素,而是投影距离的相对值,一般0.05到0.2之间效果比较好,数值越大光带越宽。_Gloss控制光带的锐利程度,数值越大,光带就越集中在中心区域,边缘更收拢,适合表现金属质感。
关于参数的具体手感,我整理了一个速查表:
| 参数 | 作用 | 推荐范围 | 备注 |
|---|---|---|---|
| _ShineColor | 光带的颜色和整体透明度 | Alpha 0.3~0.8 | 白色最百搭,金色适合卡牌品质感 |
| _ShineSpeed | 扫光速度 | 0.5~1.5 | 太快像闪光弹,太慢像没电 |
| _ShineWidth | 光带宽度 | 0.05~0.2 | 0.12附近最自然 |
| _ShineAngle | 倾斜角度 | -0.5~0.5 | 负值和正值方向相反 |
| _Gloss | 光带锐利度 | 2~5 | 越高越窄越亮,越低越柔 |
3. 图集适配:解决UV被替换的大坑
3.1 为什么图集会让扫光失效
如果你只是做个小Demo,上一段Shader已经够用。但项目稍微正经一点,一定会开Sprite Atlas。这时候你会发现扫光效果整个乱掉:要么光带只在图片左上角一小块区域闪,要么扫描方向变得歪七扭八。
原因是这样的:当Sprite被打进图集之后,Sprite Renderer生成的Mesh上,UV坐标不再是0到1的局部坐标,而是这个精灵在图集大图中所处的矩形区域坐标。比如一张1024×1024的图集,里面某个精灵占左上角64×64的区域,那么它的UV范围只有0到0.0625。我们的扫光逻辑假设UV是0到1,自然就会把光带压缩成一堆奇怪的斑点。
这个坑很隐蔽,因为从Inspector面板上看,Sprite显示是正常的,主纹理采样本身没出问题,只有扫光这种依赖归一化UV的算法才会暴露。
3.2 通过Sprite纹理矩形重建局部UV
解决方案的核心思路是:把图集UV反向映射回精灵的0~1局部UV。关键在于拿到Sprite在纹理中的矩形位置,也就是Sprite.textureRect参数。
在Shader里加一个_SpriteRect向量,四个分量分别存归一化之后的矩形左上角坐标和宽高。然后在片段着色器开头做一次逆映射:
float2 localUV = (i.uv - _SpriteRect.xy) / _SpriteRect.zw;这个操作的含义相当于:先把UV转换到图集大图上的像素位置,然后减去精灵矩形左上角的偏移,再除以精灵矩形的宽高,最后得到的就是0到1的局部UV。因为整个过程用的是归一化坐标,所以不需要关心图集实际分辨率。
非图集情况下,textureRect返回的是整张纹理本身,归一化之后_SpriteRect就是(0, 0, 1, 1),逆映射等于没做,不影响结果。所以这套方案对图集和非图集是通用的,你不需要额外分支判断。
3.3 C#调度代码与Shader联动
下面这个组件挂在SpriteRenderer上即可。脚本每帧把精灵当前的纹理矩形传递到Shader里,同时你也可以直接在这里调扫光的颜色、速度、宽度、角度等参数,用MaterialPropertyBlock传参,不破坏合批:
using UnityEngine; [RequireComponent(typeof(SpriteRenderer))] public class SpriteShineController : MonoBehaviour { public float speed = 0.8f; public float width = 0.12f; public float angle = 0.3f; public Color shineColor = new Color(1f, 1f, 1f, 0.6f); private SpriteRenderer spriteRenderer; private MaterialPropertyBlock block; private void Awake() { spriteRenderer = GetComponent<SpriteRenderer>(); block = new MaterialPropertyBlock(); } private void Update() { spriteRenderer.GetPropertyBlock(block); // 从精灵纹理矩形计算归一化UV范围,兼容SpriteAtlas Sprite sp = spriteRenderer.sprite; if (sp != null) { Texture2D tex = sp.texture; Rect r = sp.textureRect; Vector4 rect = new Vector4( r.x / tex.width, r.y / tex.height, r.width / tex.width, r.height / tex.height ); block.SetVector("_SpriteRect", rect); } block.SetColor("_ShineColor", shineColor); block.SetFloat("_ShineSpeed", speed); block.SetFloat("_ShineWidth", width); block.SetFloat("_ShineAngle", angle); spriteRenderer.SetPropertyBlock(block); } }挂上这个脚本之后,图集里任意位置的精灵都能正确扫光。你甚至可以在运行中动态更换SpriteRenderer.sprite,只要脚本还在Update,_SpriteRect就会跟着刷新。
3.4 图集打包设置的两个隐藏雷点
这个方案默认图集是“矩形打包”的,也就是每个精灵在图集里占一个水平放置的矩形区域。但Unity的Sprite Atlas有一个Tight Packing选项,开启后会根据精灵的实际形状做紧密排布,甚至可能把贴图旋转90度来节省空间。一旦开启Tight Packing,Sprite.textureRect返回的矩形可能不再是你以为的那个矩形,扫光区域就会错乱。
解决方案有两个:一个是在图集的Type设置为Tight时不使用这个Shader,改用矩形Packing模式;另一个是在代码里检查spackedSprite相关的旋转信息,但处理起来非常繁琐。我的建议是,如果是美术主导的项目,扫光精灵尽量单独放一个图集,关闭Tight Packing和Rotation,省心很多。
还有一个坑是图集变体。如果你的图集是多倍率变体(比如1x和2x),textureRect是基于当前Sprite的引用纹理计算的,Shader里用归一化坐标处理,所以只要代码里用的是sp.texture和sp.textureRect,就不会受到倍率影响。这点我实测过,可以放心用。
4. 多对象扫光与性能控制
4.1 直接调整材质的代价
场景里有多个需要扫光的道具卡时,一个常见错误是写成这样:
GetComponent<SpriteRenderer>().material.SetFloat("_ShineSpeed", 0.8f);这条代码会自动为当前SpriteRenderer创建一个材质实例,哪怕你只是改了一个浮点数。场景里如果有十个不同参数的卡牌,就会多出十个材质实例,DrawCall涨上去不说,内存里也全是重复的材质数据。
正确的做法是使用MaterialPropertyBlock。它是专门为这种“共享同一个材质,但每个渲染对象需要不同参数”的场景设计的。用PropertyBlock传参,不会产生材质实例,同时不影响Unity的动态合批。这就是为什么我在上面的C#脚本里选择用GetPropertyBlock/SetPropertyBlock而不是直接改material。
4.2 用PropertyBlock实现独立扫光相位
多对象扫光时还有一个小问题:如果所有对象都共享同一份材质,那么它们的扫光永远是同步的,一排卡牌同时亮光,非常整齐也非常傻。每个对象错峰扫光看起来更自然,随机扫出一种“此起彼伏”的流动感。
处理方法很简单,给每个对象一个随机偏移相位:
private float phaseOffset; private void Awake() { // 每个实例随机错峰,避免扫光齐刷刷同步 phaseOffset = Random.Range(0f, 1f); } private void Update() { // 把相位偏移传给Shader,让光带起扫位置错开 block.SetFloat("_PhaseOffset", phaseOffset); }对应的Shader里只需要把周期量的计算改一下:
float center = (frac(_Time.y * _ShineSpeed + _PhaseOffset) - 0.5) * 1.6;在Shader的属性里加一个_PhaseOffset浮点数即可。这样一个材质、多个属性块、每个对象扫光位置不同,而且完全不影响合批性能。实测在一个场景里放二三十个带扫光的对象,DrawCall没有增加任何明显负担。
4.3 合批策略与参数建议
使用Sprite Renderer时,合批的前提条件是:所有Sprite共用同一个材质。用了PropertyBlock之后,只要图集相同,扫光Shader参数不同不会拆批。但有几个操作会破坏合批,需要特别注意:
- 不要给SpriteRenderer直接挂不同材质实例。
- 不要用
renderer.material索引器去改参数。 - 如果需要调节单个对象的颜色,也走PropertyBlock而不是改材质。
另外,如果场景里有大量扫光对象,建议每帧不要更新PropertyBlock,改成只有在参数变化时才更新,或者只在初始化时更新一次。因为GetPropertyBlock和SetPropertyBlock本身也有开销,数量多到上百个时,每帧的调用量不可忽视。我通常只在开始变化、切换状态时Set,平时让Shader里的_Time自己驱动循环,这就够了。
5. 常见问题与排查技巧
5.1 扫光不显示或一片紫
出现紫色,通常是Shader编译失败。先看Console窗口的红色报错,最常见的原因有两类:一类是CGPROGRAM里引用了不存在的变量或者语法写错,仔细检查有没有缺分号;另一类是渲染管线不匹配,比如项目用的URP,但Shader用的是Built-in的CG语法。
如果是URP,建议直接用Shader Graph做一套同样的逻辑,或者把Shader改成HLSL并引用URP的ShaderLibrary。核心算法不需要改思路,只要把顶点变换函数从UnityObjectToClipPos换成URP的TransformObjectToHClip即可。Shader Graph实现路径是:Sample Texture 2D -> 用UV减Sprite Rect -> 投影距离计算 -> 时间驱动 -> 叠加颜色,画完节点扫描原理完全一致。
5.2 透明区域也能看到光带
这个问题我在2.3节提过,但还是要单独说,因为实在是太常见了。现象是扫光时,人物裙摆镂空的地方也会有光带闪过,像隔着一层玻璃在看光。
解决方案就是那句c.rgb += _ShineColor.rgb * shine * c.a,务必乘上主纹理采样后的Alpha。如果你希望光带只在完全不透明区域生效,还可以把这个c.a换成step(0.5, c.a),把Alpha超过0.5的像素认定为不透明,镂空边缘会有更锐利的落点,不过通常乘Alpha就够用了。
5.3 图集下扫光区域错乱
确认是否挂了SpriteShineController并且每一帧都在传_SpriteRect。如果传了还是错乱,优先检查图集的Tight Packing是否开启。另外还有一个我踩过的坑:某些精灵Asset勾选了Packed标记,但实际没有打图集,导致sp.packed返回true而textureRect异常。代码里可以用sp.packed && sp.packingMode != SpritePackingMode.Rectangle做一次判断,如果是非矩形打包,直接回退成不扫光或者临时用默认材质。
5.4 扫光方向的直觉调节
很多第一次用的人会把_ShineAngle理解成“倾斜角度”,其实它更像“斜率”。_ShineAngle = 0时是竖直光带从右往左扫,等于0.3时带一点斜度。如果想光带完全水平扫过,把dir改成normalize(float2(0, 1))或normalize(float2(1, 0))这类固定向量。别被0.3这个默认值带偏,扫光方向并没有绝对的对错,你要根据卡面构图反复试,找到最顺眼的角度。
6. 一点个人经验
最后分享一个我自己的调参习惯。扫光这种效果,参数给得太“足”反而容易显得廉价。很多项目里的扫光失败,不是技术没实现,而是光带太宽、速度太快、整张图都在疯狂闪。我一般会把_ShineWidth先压到0.08,_Gloss调到3以上,让光带窄一点、锐一点,这种细光带扫过去的感觉最像“高级卡”的质感。
另外,如果精灵经常动态更换,记得在换Sprite之后强制重调一次PropertyBlock,因为textureRect变了而Shader里还是旧的_SpriteRect,扫光就飘了。这个小细节排查起来特别花时间,写在这也算帮大家省一晚上Debug的功夫。