资讯动态

Unity 2D 扫光效果实现:Sprite Renderer 下基于 Shader 的流光方案

发布时间:2026/10/20 16:21:49 来源:尧图企业网站定制
最近做卡牌抽奖界面时遇到一个特别典型的2D表现需求角色立绘在Sprite Renderer下抽中稀有卡的一瞬间需要有一道金色光效从左上往右下扫过去。一开始我下意识想用粒子或者Tween动画硬做结果发现要么位置对不齐、要么边缘太假。后来把思路切到shader方向用uv投影把光束做成参数化的数学条带才算真正解决了问题。这篇博文就从Sprite Renderer条件下快速实现扫光效果这个具体场景出发把完整思路、可直接复制使用的Shader代码、接入流程、以及我在真机和图集下踩过的坑一次性说清楚。适合正在做2D抽卡、剧情对话高亮、装备图标光效的Unity开发者参考尤其是项目已经切到URP、又不想为一个小效果引入复杂特效插件的情况。1. 扫光效果为什么在Sprite Renderer里绕不开1.1 从抽卡金光说起扫光的真实需求扫光也叫流光、shine sweep在2D游戏里出现频率极高。抽卡结果揭示、新物品获得、技能解锁、UI按钮悬停、甚至血条异常提示都依赖一道有方向感的光条快速掠过目标表面用来把玩家注意力瞬间拽到指定区域。在3D场景里扫光往往通过投影贴图、全屏后处理或者粒子系统解决。一旦切换到Sprite Renderer情况就变了。Sprite Renderer本质上是把一个四边形Mesh和一张纹理交给渲染管线绘制它没有内置的材质扰动接口也没有方便的光照模型。想在精灵表面加一道动态光束最直接的思路就是在它使用的Shader里做片段着色计算。每帧渲染时对每个像素进行判断当前像素是否落在光束条带范围内如果是就把光束颜色叠加到最终输出上。这个方案的优点是不需要额外美术资源、不需要动画师手动K帧、任意Sprite换上去就能用、效果和Sprite本身的透明度、颜色调节完全兼容。相比粒子方案它在光效是否完全贴合精灵轮廓这一点上有天然优势因为shader只作用于精灵本身的可见像素永远不会出现光束跑到图片外面去的尴尬。1.2 3D扫光方案为什么不能直接搬到2D很多Unity开发者是从3D项目转到2D的习惯性地想用Projector或者粒子拖尾来做扫光。这里有个认知偏差3D扫光依赖世界空间坐标和光源方向而Sprite Renderer渲染的是一张平铺的纹理缺乏几何体纵深。Projector在2D相机下要么投影矩阵别扭要么干脆不显示粒子拖尾看起来像是飘过去的碎屑而不是擦过表面的光带。还有一个更隐蔽的问题Sprite Renderer使用的Mesh通常是Unity自动生成的简单位置整个Sprite只有四个顶点纹理坐标范围基本是0到1。想要做出光带从左往右移动的感觉必须依赖片段着色器在像素级别做运算而不是通过顶点着色器改变形状。理解了这一点后续的shader方案就顺理成章了。2. 先排掉两个伪方案再决定核心思路2.1 预烘焙序列帧美术资源成本与灵活性很多团队第一种想到的方案是让美术直接做一段金光扫过的序列帧然后配合Animation播放。这个方案最大的优势是效果好、完全可控美术想画成什么样就画成什么样。但代价也很明显序列帧占内存、不同尺寸的Sprite需要不同分辨率的关键帧、换一套UI风格就要重新出图、运行时控制光束位置和速度也非常麻烦。如果只是给某一个交互做一个一次性特效序列帧确实可以。但如果你是做一个需要复用到大量Sprite身上的通用组件序列帧方案维护成本会直线上升。我给某休闲游戏项目做道具展示系统时最开始美术出了8张金色扫光序列帧放到四款不同尺寸的道具图标上后拉伸情况非常严重最后全部废弃统一换成了shader方案。2.2 子Sprite旋转/平移的假扫光原型够用上线不够还有一种做法让人又爱又恨在目标Sprite下挂一个子Sprite用一张横向的渐变长条图通过旋转加平移模拟光束扫过的感觉。这个方法胜在零代码、零Shader3分钟就能看到效果做前期玩法原型验证完全够用。但上线就不行了。第一个坑长条图旋转后边缘是矩形和Sprite的轮廓重叠时会出现生硬的切边玩家一眼就能看出上面有个方块在飘。第二个坑需要手动管理很多条带的角度、透明度、遮挡层级稍不注意就会出现光束穿过不该穿过的物体。第三个坑一旦需要斜向扫光长条图必须旋转和缩放同时调像素密度不匹配导致边缘锯齿缩放后贴图模糊。我当时用这个方法做了一个内部白模版本最后结论是作为低保真原型可以作为正式表现必须走shader。2.3 核心思路用uv投影把光束变成数学条带抛弃序列帧和子Sprite之后核心思路变得非常清晰在Sprite的Shader中把每个像素的uv坐标当成一个二维点然后定义一个移动的光束中心线通过计算像素点到中心线的投影距离来判断它是否在光束范围内。用生活化类比可以把Sprite想象成一扇窗户光束就是一条沿着窗户来回扫的灯光带。shader要做的事情是让窗户玻璃上每个透光的点判断自己属于灯光照到的地方还是没有照到的地方再决定要不要变亮。这个判断只需要一次点乘和几次比较在现代GPU上开销几乎可以忽略。3. 光束的数学表达方向、循环、边缘柔化3.1 任意方向光束投影坐标才是关键扫光最常见的方向是水平或垂直但真正让效果显得高级的往往是斜向扫光。如果直接拿uv.x作为光束位置的判据只能做水平方向。为了支持任意角度我引入了一个方向向量让每个像素的uv先做中心化处理再和方向向量做点积得到一个投影标量。假设Sprite的uv范围是0到1中心化后的坐标范围就是-0.5到0.5。定义光束方向向量dir那么每个像素的投影值projection dot(centeredUV, dir)。这个投影值的大小就相当于这个像素在光束方向上的位置坐标。光束本身只需要在这个投影轴上定义一段区间就能覆盖任意旋转角度的条带。3.2 时间循环让光束自己扫起来光束要动就得有一个随时间变化的位置。Unity shader中可以直接读取_Time.y作为全局时间。为了让光束能够周期性地从一端扫到另一端再重新开始我使用了frac函数把时间值限制在0到1之间然后用lerp把0到1映射到光束移动范围的起点和终点。我之前踩过一个细节如果不假思索地让光束中心在-0.5到0.5之间线性移动斜向扫光时投影范围的实际极值可能超过0.5导致光束在图像边缘消失得比较早。后来我把扫描范围稍微收紧到-0.45到0.45同时把光束宽度参数纳入考量视觉效果就稳了很多。3.3 边缘柔化避免一条生硬的亮带如果直接用阶梯判断投影值在区间内则亮、区间外则暗光束边缘会出现明显的锯齿或硬边看起来像一条被抠图抠出来的塑料条。正确的做法是用smoothstep对光束左右两个边缘做平滑过渡。smoothstep的本质是把一个输入值在两个边界之间做Hermite插值输入小于左边界返回0大于右边界返回1中间平滑过渡。我把这个特性分别用在光束的左右边缘上左边缘的smoothstep负责从0过渡到1右边缘的smoothstep负责从1过渡到0两者相减就得到中间亮、两侧渐渐变暗的光带。这个过渡宽度由_SweepSoftness参数控制数值越小边缘越锐利越大越柔和。3.4 参数化设计什么参数该暴露出来一个可以被团队其他成员直接使用的扫光Shader参数设计非常重要。我的经验是至少暴露以下参数参数作用建议取值范围_SweepColor光束颜色决定金色、白色还是自定义色颜色选择器_SweepIntensity光束叠加强度影响亮度和氛围感0.5 到 2.0_SweepSpeed光束循环速度0.2 到 3.0_SweepWidth光束宽度0.02 到 0.4_SweepSoftness光束边缘柔化程度0.001 到 0.3_SweepDirX / _SweepDirY光束方向向量分量-1 到 1_AutoSweep是否自动循环扫光0或1_SweepPhase初始相位用于错开多个Sprite的光束0 到 1_SweepOffset手动模式下光束的绝对位置-0.5 到 0.5把方向拆成两个分量而不是用一个角度值是为了避免在Shader里写三角函数。方向向量经过normalize之后任意角度都可以通过Inspector里的两个滑块直观调整。4. 一份可直接复制的URP扫光Shader4.1 Shader完整代码下面这份Shader基于URP编写使用HLSL兼容SRP Batcher。把它保存成.shader文件挂到Sprite Renderer上就能看到效果。我在注释里把关键逻辑都标出来了。Shader Custom/2D/SpriteSweepShine { Properties { _MainTex (Sprite Texture, 2D) white {} _Color (Tint, Color) (1,1,1,1) _SweepColor (Sweep Color, Color) (1,0.85,0.4,1) _SweepIntensity (Sweep Intensity, Range(0, 3)) 1.0 _SweepSpeed (Sweep Speed, Range(0, 5)) 1.0 _SweepWidth (Sweep Width, Range(0.01, 0.5)) 0.15 _SweepSoftness (Sweep Edge Softness, Range(0.001, 0.3)) 0.03 _SweepDirX (Sweep Dir X, Range(-1, 1)) 1 _SweepDirY (Sweep Dir Y, Range(-1, 1)) 0 _AutoSweep (Auto Sweep, Float) 1 _SweepPhase (Sweep Phase, Range(0, 1)) 0 _SweepOffset (Sweep Offset, Range(-0.5, 0.5)) 0 } SubShader { Tags { Queue Transparent RenderType Transparent RenderPipeline UniversalPipeline IgnoreProjector True CanUseSpriteAtlas True } Blend SrcAlpha OneMinusSrcAlpha Cull Off ZWrite Off Pass { HLSLPROGRAM #pragma vertex vert #pragma fragment frag #include Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl struct Attributes { float4 positionOS : POSITION; float2 uv : TEXCOORD0; float4 color : COLOR; }; struct Varyings { float4 positionCS : SV_POSITION; float2 uv : TEXCOORD0; float4 color : COLOR; }; TEXTURE2D(_MainTex); SAMPLER(sampler_MainTex); CBUFFER_START(UnityPerMaterial) float4 _MainTex_ST; float4 _Color; float4 _SweepColor; float _SweepIntensity; float _SweepSpeed; float _SweepWidth; float _SweepSoftness; float _SweepDirX; float _SweepDirY; float _AutoSweep; float _SweepPhase; float _SweepOffset; CBUFFER_END Varyings vert(Attributes IN) { Varyings OUT; OUT.positionCS TransformObjectToHClip(IN.positionOS.xyz); OUT.uv IN.uv.xy; OUT.color IN.color; return OUT; } half4 frag(Varyings IN) : SV_Target { half4 tex SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, IN.uv) * IN.color; half alpha tex.a * _Color.a; float2 dir normalize(float2(_SweepDirX, _SweepDirY)); float2 centeredUV IN.uv - 0.5; float proj dot(centeredUV, dir); float cycle frac(_Time.y * _SweepSpeed _SweepPhase); float center lerp(-0.45, 0.45, cycle) _SweepOffset; float halfWidth _SweepWidth * 0.5; float beam 0; if (_AutoSweep 0.5) { float left smoothstep( center - halfWidth - _SweepSoftness, center - halfWidth _SweepSoftness, proj); float right smoothstep( center halfWidth - _SweepSoftness, center halfWidth _SweepSoftness, proj); beam left - right; } else { float dist abs(proj - center); beam 1.0 - smoothstep( halfWidth - _SweepSoftness, halfWidth _SweepSoftness, dist); } half3 outputRGB tex.rgb _SweepColor.rgb * beam * _SweepIntensity * alpha; half outputA alpha; return half4(outputRGB, outputA); } ENDHLSL } } }4.2 关键代码段逐行解析让我把最核心的几段拎出来单独讲。首先是投影计算。centeredUV IN.uv - 0.5这行把uv坐标原点从角落移到中心这样方向向量的正负语义就和直觉一致dirX为正时光束往右斜dirX为负时往左斜。proj dot(centeredUV, dir)得到的是一个标量这个标量描述了像素在光束方向轴上的位置。然后是光束中心和边缘。center由循环时间控制halfWidth决定光束半宽。left - right的写法是边缘柔化最简洁的形态left在光束左边缘做了一次过渡right在右边缘做了一次过渡两者相减得到一个中间为1、两侧渐变为0的条带。注意smoothstep的参数顺序不能写反否则会出现亮带边缘反白的问题。最后是颜色叠加。我把光束颜色乘上beam和_SweepIntensity再加到原始贴图颜色上。这里特别乘了alpha确保光束不会在Sprite的透明区域外面产生光晕。最终输出的alpha仍然是原始alpha所以遮罩、淡入淡出等操作不会因为扫光而异常。4.3 如果你还在用内置渲染管线如果你的项目还停留在Build-in Render Pipeline这份Shader不能直接扔进去用但改动量很小。需要修改的位置有三个把include换成UnityCG.cginc把TransformObjectToHClip换成UnityObjectToClipPos把SAMPLE_TEXTURE2D换成tex2D。CBUFFER在非SRP Batcher环境下也可以保留Unity会忽略其中的跨管线语义。整体逻辑一行都不用改。另外提醒一下URP的2D Renderer如果开启了2D光照你会考虑是否让扫光Sprite受光源影响。默认情况下这份Shader是一个无光照Pass好处是扫光颜色稳定可预测不会因为环境光照导致金色变成土黄色。如果确实需要参与2D光照可以在Pass里加上LightMode Universal2D的Tag但需要额外处理法线纹理表现上也会复杂很多。5. 接入Sprite Renderer的完整流程与组件5.1 从Shader到材质的三个步骤步骤一在Project窗口右键创建Shader文件命名后把上面的代码完整粘贴保存。步骤二右键创建MaterialShader下拉框里选择Custom/2D/SpriteSweepShine然后把材质拖到任意Sprite Renderer的Material字段上。步骤三选中Sprite Renderer所在物体在Inspector里调节扫光参数。如果一切正常Play模式下就能看到光束在精灵表面来回扫动。这里有一个容易忽略的细节Sprite Renderer默认的材质是Sprites/Default当你替换成自定义Shader后一定要确认材质球上的_Color不是黑色否则Sprite会直接消失。这个报错不会在Console里出现只会表现为挂上材质后图片不见了排查起来非常迷惑。5.2 手动控制组件MaterialPropertyBlock的用法自动循环扫光适合常驻高亮但游戏里更多需求是事件驱动的扫一下。比如抽卡结果出现时光束从头到尾扫一次然后停在一边。这种场景下_AutoSweep应该设为0然后通过外部脚本动态设置_SweepOffset。我强烈建议用MaterialPropertyBlock而不是直接修改material。原因很简单直接改material会为每个Sprite生成新的材质实例内存会膨胀而且破坏了Unity的合批机制。MaterialPropertyBlock允许你在不改动共享材质的前提下为单个渲染器提交覆盖属性这才是不增加Draw Call的做法。下面是完整可用的控制组件我通常命名为SpriteSweepControllerusing UnityEngine; [RequireComponent(typeof(SpriteRenderer))] public class SpriteSweepController : MonoBehaviour { [Range(-0.5f, 0.5f)] public float sweepOffset 0.5f; [Range(0f, 1f)] public float sweepPhase 0f; private SpriteRenderer spriteRenderer; private MaterialPropertyBlock block; private void Awake() { spriteRenderer GetComponentSpriteRenderer(); block new MaterialPropertyBlock(); } public void SetSweepOffset(float value) { sweepOffset value; Apply(); } public void PlayOnce(float duration) { StopAllCoroutines(); StartCoroutine(PlayRoutine(duration)); } private System.Collections.IEnumerator PlayRoutine(float duration) { float time 0f; float start -0.45f; float end 0.45f; while (time duration) { time Time.deltaTime; SetSweepOffset(Mathf.Lerp(start, end, time / duration)); yield return null; } SetSweepOffset(end); } private void Apply() { if (spriteRenderer null) return; spriteRenderer.GetPropertyBlock(block); block.SetFloat(_SweepOffset, sweepOffset); block.SetFloat(_SweepPhase, sweepPhase); spriteRenderer.SetPropertyBlock(block); } }这里有个容易被新手忽略的要点使用PropertyBlock之前先调用GetPropertyBlock再写入字段而不是直接new MaterialPropertyBlock然后Set。前者能保留Unity内部给SpriteRenderer设置的其他属性后者会覆盖掉一些隐式设置极端情况下会导致Sprite渲染异常。5.3 与动画、事件系统联动扫光最常见的使用方式有三种一是挂在角色身上常驻等待二是抽卡翻牌瞬间调用PlayOnce三是UI面板出现时通过AnimationClip直接驱动sweepOffset字段。如果用Animator驱动直接在Animation Window里给sweepOffset录制关键帧从-0.45到0.45即可不需要写任何代码。要注意的是Animator驱动的是一个MonoBehaviour的public字段最终通过PropertyBlock写入Shader这个过程每帧会有一次属性设置开销但相比整个UI系统的耗时可以忽略不计。如果项目使用UGUI扫光挂在Image上时会走Graphic的材质体系和Sprite Renderer不完全一样。UGUI的Image默认使用UI/Default要做扫光需要让Shader在UI环境下正确处理裁剪矩形。这里先不展开等有机会单独写一篇。本文的场景限定在Sprite Renderer但它同样可以用在Tilemap的单个Tile上只要确保材质实例正确处理即可。6. 实测中踩过的坑与排查链路6.1 故障一材质挂上去了扫光死活不显示这是出现频率最高的问题。排查链路通常是这样的第一步确认Shader有没有编译报错打开Window General Console看有没有紫红色报错第二步排除时间冻结问题如果游戏处于暂停状态_Time.y不会增加自动扫光自然纹丝不动第三步验证参数把_SweepOffset手动改成0_SweepWidth改成0.3看是否有静止光束出现。如果静止光束正常但动不了那就是时间相关的问题。Unity在Time.timeScale 0时确实会冻结_Time如果你希望扫光在暂停菜单里也保持运动比如暂停界面的背景流光就需要在C#侧传一个自定义时间进去把Shader里的_Time.y换成一个外部uniform时间变量。6.2 故障二扫光方向在部分平台反过来这个坑非常隐蔽。Sprite Renderer在DX和OpenGL平台上uv坐标系的V轴方向存在差异。虽然Unity在大多数情况下会自动处理纹理采样翻转但当我用uv坐标参与数学计算时方向向量就可能出现Y轴翻转。我自己实测过编辑器Windows平台下方向朝右上打包到Android模拟器里变成了右下。解决方式有两种一种是在Shader里用float ySign (_ProjectionParams.x 0 ? 1 : -1)这类平台判定做方向修正另一种更省事是在不同平台单独调整_SweepDirY的符号。考虑到扫光方向本身是美术参数我更倾向于后者——在真机上确认方向不对时直接把_SweepDirY取反即可不值得为了平台兼容加入复杂的宏判断。6.3 故障三图集与CustomMesh导致UV偏移Sprite Atlas是2D项目绕不开的。把Sprite打入图集后Sprite Renderer传给Shader的uv是整个图集中的局部矩形坐标不再是0到1。好消息是Unity在渲染Sprite时会自动把uv改写成图集中的实际区域Shader里拿到的仍然是这个Sprite自身范围内的uv。真正出问题的地方是自定义Mesh。如果通过SpriteRenderer.customMesh属性传入一个自定义网格同时网格的uv不是按图集局部坐标生成的扫光位置就会偏移。我之前在做一个变体形状的卡牌外框时遇到过Mesh的uv我手动建模时把范围设成了0到1打入图集后整张图显示错乱扫光也跟着歪。排查方法是把_MainTex的Tiling设成2看看uv是不是出现了重复平铺如果是就要在Shader里针对自定义Mesh单独传入一个uvRect做归一化。6.4 故障四光束变成两条边或整块亮如果光束不是一条渐进明暗的光带而是两条平行亮线多半是smoothstep的过渡参数写反了。比如把smoothstep(edge0, edge1, x)的两个边界写成先大后小结果数值跳变左右两个边缘各自形成一条亮线。正确的书写一定是edge0 edge1。另一种情况是整张Sprite均匀发亮看不到光束形状。这种通常是_SweepWidth设得太大投影值范围最多不超过0.7左右宽度超过0.5时整张图基本都在光束范围内。建议从0.1开始调逐步放小。还要检查_SweepDirX和_SweepDirY是否一个为0另一个为0normalize一个零向量在GPU上会得到不确定结果表现为光束完全消失。7. 性能、合批与延伸玩法7.1 共享材质与MPB的正确姿势扫光Shader本身的性能开销非常低frag里只有一次点积、两次smoothstep和一次颜色混合。真正的性能风险在于材质实例数量和合批破坏。正确做法是整个项目只创建一份扫光材质所有需要用扫光的Sprite Renderer都用同一个材质文件各自的差异通过MaterialPropertyBlock提交。这样材质数量是1Draw Call增加与否取决于MPB的使用方式。如果两个Sprite使用同一材质且MPB属性相同仍然可以合批一旦MPB属性不同合批就会被打断每个Sprite变成独立Draw Call。考虑到扫光元素通常数量不多这个代价是可以接受的。7.2 SRP Batcher下的注意事项上面给的Shader已经在CBUFFER里声明了所有材质属性完全兼容SRP Batcher。这意味着即使扫光Sprite很多只要它们使用同一份着色器变体SetPass的次数也会被大幅压缩。在URP环境下你可以在Frame Debugger里观察扫光Shader的Pass如果显示绿色SRP Batch说明已经命中了合批路径。有一个小细节Shader里那个if (_AutoSweep 0.5)的分支属于uniform分支所有像素走同一条路径GPU不会产生线程发散这个不用担心。真正需要避免的是在frag里写依赖uv的if条件那才会导致分支代价。7.3 延伸多段光束、径向扫光、噪声边缘这套扫光思路的好玩之处在于掌握了投影条带模型之后可以衍生出很多变体。想让一道金色光束变成两三条并排扫过只需要在同一个projection轴上叠多个不同位置和宽度的小条带想让光束呈径向扫光效果适合圆形的金币、徽章把中心化uv点乘改成距离中心和角度计算用atan2算出角度后沿角度轴做条带想让光束边缘更自然可以用一张噪声纹理采样后的结果去扰动_SweepSoftness让边缘出现不规则抖动模拟光线穿过毛玻璃的感觉。我自己在实际项目中用得最多的是多段光束和噪声边缘这两个变体前者用于高级武器强化提示后者用于异空间入口这类带有神秘感的场景表现。最后再分享一个个人经验扫光效果虽然是Shader负责算但真正决定观感的往往是光束颜色和背景的对比度。金色扫光在暖色调背景下容易糊成一团记得把_SweepIntensity调到1.2以上或者在Sprite底下加一层暗色底图做衬托。这个细节在我做过的多个项目里比任何算法优化都更立竿见影。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑