Unity Sprite Renderer扫光Shader指南:图集适配与性能优化
做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/DefaultTags { QueueTransparent RenderTypeTransparent PreviewTypePlane CanUseSpriteAtlasTrue } 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 { QueueTransparent RenderTypeTransparent PreviewTypePlane CanUseSpriteAtlasTrue } 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.20.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 GetComponentSpriteRenderer(); 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 PackingSprite.textureRect返回的矩形可能不再是你以为的那个矩形扫光区域就会错乱。解决方案有两个一个是在图集的Type设置为Tight时不使用这个Shader改用矩形Packing模式另一个是在代码里检查spackedSprite相关的旋转信息但处理起来非常繁琐。我的建议是如果是美术主导的项目扫光精灵尽量单独放一个图集关闭Tight Packing和Rotation省心很多。还有一个坑是图集变体。如果你的图集是多倍率变体比如1x和2xtextureRect是基于当前Sprite的引用纹理计算的Shader里用归一化坐标处理所以只要代码里用的是sp.texture和sp.textureRect就不会受到倍率影响。这点我实测过可以放心用。4. 多对象扫光与性能控制4.1 直接调整材质的代价场景里有多个需要扫光的道具卡时一个常见错误是写成这样GetComponentSpriteRenderer().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的功夫。