ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

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

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

最近做卡牌抽奖界面时,遇到一个特别典型的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,用一张横向的渐变长条图,通过旋转加平移模拟光束扫过的感觉。这个方法胜在零代码、零Shader,3分钟就能看到效果,做前期玩法原型验证完全够用。

但上线就不行了。第一个坑:长条图旋转后边缘是矩形,和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扫光Shader

4.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文件,命名后把上面的代码完整粘贴保存。步骤二:右键创建Material,Shader下拉框里选择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的做法。

下面是完整可用的控制组件,我通常命名为SpriteSweepController:

using 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 = GetComponent<SpriteRenderer>(); 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另一个为0,normalize一个零向量在GPU上会得到不确定结果,表现为光束完全消失。

7. 性能、合批与延伸玩法

7.1 共享材质与MPB的正确姿势

扫光Shader本身的性能开销非常低,frag里只有一次点积、两次smoothstep和一次颜色混合。真正的性能风险在于材质实例数量和合批破坏。

正确做法是:整个项目只创建一份扫光材质,所有需要用扫光的Sprite Renderer都用同一个材质文件,各自的差异通过MaterialPropertyBlock提交。这样材质数量是1,Draw 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底下加一层暗色底图做衬托。这个细节在我做过的多个项目里比任何算法优化都更立竿见影。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进