做2D游戏的朋友应该都有过这种需求:角色死亡时来一段消散动画、怪物被打败后碎成几块消失、UI面板切换时不想干巴巴直接淡出。网上搜一圈,能找到的溶解Shader基本都是3D版,主角是模型,拉到2D Sprite上要么锯齿严重,要么直接不生效。这篇文章从一个最简单的“Unity2D图片溶解扩散效果Shader”说起,把噪声图、阈值、边缘光、扩散方向这些关键点全部讲透,最后给出可以直接放进项目的完整代码。适合正在做2D游戏、想自己掌控溶解表现的开发者,也适合刚接触Shader想找项目练手的朋友。
1. 核心思路:一张噪声图就能“撕碎”图片
1.1 溶解的数学本质:噪声值与阈值的拔河
先抛开Shader语法,想清楚溶解到底在做什么。一张普通图片每个像素有自己的颜色,要让图片“消失”,最直接的办法是让一部分像素变透明,另一部分保留。问题在于:怎么决定哪些像素先消失、哪些像素后消失?全用随机器在GPU里不好控制,而且逐帧随机会产生闪点乱跳。
业界的标准做法是引入一张“噪声图”。所谓噪声图,就是一张每个像素灰度值都不同的图,白色区域值接近1,黑色区域值接近0,中间是各种深浅不一的灰。把每个像素的噪声值和溶解进度做比较:当某个像素的噪声值比阈值低,它就被“溶解”掉;比阈值高,就保留。随着阈值从0慢慢涨到1,越来越多的像素被淘汰,最终整张图消失。
这就像往一片高低不平的礁石滩放水。噪声图是礁石的地形,白色是凸起的高地,黑色是低洼。溶解进度就是水位,水位抬升,低洼处先被淹没,高地撑到最后。所以我们看到的溶解边缘永远是不规则的、参差的,而不是一条直线从左边推到右边。这个不规则感,就是溶解效果“好看”的根源。
1.2 “扩散”两字怎么实现:距离场给阈值做空间加权
“溶解扩散”里的关键词是扩散。噪声溶解是从随机斑点蔓延,方向不可控;而扩散会在视觉上形成“以某个点为中心,一圈一圈向外烧开”的感觉,像纸张从中心点燃、或者冰霜从核心裂开。
实现方式是在阈值比较之前,先算每个像素到指定中心的距离。距离近的像素,给它一个更高的“局部阈值”,让它在更早的阶段就被溶解;距离远的像素,局部阈值偏低,留到最后。这样自然就产生了一个从中心向外扩张的溶解前锋。
实际计算时,把UV坐标减去扩散中心点坐标,取长度就得到距离。然后把这个距离除以一个扩散范围值,归一化到0到1,这就是“扩散因子”。中心点扩散因子为0,边缘为1。之后让局部阈值从溶解进度插值到溶解进度的0.2倍:越靠近中心,阈值越接近完整的溶解进度;越靠外,阈值越小。最终效果就是中心先碎,外层后碎,溶解区域沿径向推进。
也可以把这个扩散因子反转,变成由外向内收缩的效果:边缘先消失,中心最后残留。玩法上可以实现“敌人从画面中央凝聚出现”,或者“保护罩从外向内破裂”。一个参数控制方向,很划算。
2. 完整Shader逐段解析:从Properties到片元着色器
2.1 参数设计:暴露哪些接口给美术和策划
Shader写出来是给美术调参数用的,所以属性面板够不够直观,直接决定这个东西好不好用。我的参数设计如下:
| 参数 | 类型 | 作用 |
|---|---|---|
| _MainTex | 2D | Sprite图片,由SpriteRenderer自动传入 |
| _NoiseMap | 2D | 噪声图,决定溶解边缘的形状 |
| _DissolveAmount | Range(0,1) | 溶解进度,0为完整,1为全部消失 |
| _EdgeWidth | Range(0,0.2) | 边缘发光带的宽度 |
| _EdgeColor | Color | 溶解边缘的燃烧色/描边色 |
| _EdgeStrength | Range(0,2) | 边缘色混合强度 |
| _Center | Vector | 扩散中心点,UV坐标空间 |
| _SpreadRange | Range(0,1) | 扩散范围,控制溶解前锋从中心到边缘的跨度 |
| _Direction | Float | 扩散方向,1为内向外,-1为外向内 |
| _FadeWidth | Range(0,0.1) | 边缘羽化宽度,越大越柔和 |
_MainTex 上加了一个 [PerRendererData] 标记。这个标记很重要:它告诉Unity这个属性是每个渲染器自己传数据的,不要序列化在材质球里。SpriteRenderer默认就是用MaterialPropertyBlock传Sprite贴图的,不加这个标记,在部分Unity版本下会出现资源引用混乱或无法正常取图的问题。
除了上面这些,还要在Shader里写清楚透明渲染需要的状态。2D Sprite默认是透明混合的,所以SubShader里必须设置:
Tags { "Queue"="Transparent" "RenderType"="Transparent" "IgnoreProjector"="True" } Blend SrcAlpha OneMinusSrcAlpha Cull Off Lighting Off ZWrite OffQueue和RenderType都走透明队列,保证Sprite的层级排序正常。ZWrite Off是因为透明物体不需要写入深度,否则会被后面的半透明物体挡住或产生排序问题。Cull Off让图片正反两面都能看到,2D Sprite一般不背面剔除,保险起见关掉。
2.2 顶点着色器和片元着色器关键代码
完整Shader如下,这段代码可以直接复制到Unity工程里用:
Shader "Custom/2D Dissolve Spread" { Properties { [PerRendererData] _MainTex ("Sprite Texture", 2D) = "white" {} _NoiseMap ("Noise Map", 2D) = "white" {} _DissolveAmount ("溶解进度", Range(0, 1)) = 0 _EdgeWidth ("边缘宽度", Range(0, 0.2)) = 0.05 _EdgeColor ("边缘颜色", Color) = (1, 0.5, 0.15, 1) _EdgeStrength ("边缘强度", Range(0, 2)) = 1 _Center ("扩散中心(UV)", Vector) = (0.5, 0.5, 0, 0) _SpreadRange ("扩散范围", Range(0, 1)) = 0.7 _Direction ("扩散方向(1=由内向外, -1=由外向内)", Float) = 1 _FadeWidth ("边缘羽化宽度", Range(0, 0.1)) = 0.02 } SubShader { Tags { "Queue"="Transparent" "RenderType"="Transparent" "IgnoreProjector"="True" "PreviewType"="Plane" } Blend SrcAlpha OneMinusSrcAlpha Cull Off Lighting Off ZWrite Off Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include "UnityCG.cginc" sampler2D _MainTex; float4 _MainTex_ST; sampler2D _NoiseMap; float4 _NoiseMap_ST; float _DissolveAmount; float _EdgeWidth; float4 _EdgeColor; float _EdgeStrength; float4 _Center; float _SpreadRange; float _Direction; float _FadeWidth; struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float4 pos : SV_POSITION; float2 uv : TEXCOORD0; }; v2f vert(appdata v) { v2f o; o.pos = UnityObjectToClipPos(v.vertex); o.uv = TRANSFORM_TEX(v.uv, _MainTex); return o; } fixed4 frag(v2f i) : SV_Target { // 1. 计算像素到扩散中心的归一化距离 float2 offset = i.uv - _Center.xy; float dist = length(offset); float spread = saturate(dist / max(_SpreadRange, 0.0001)); // 2. 支持由内向外或由外向内扩散 if (_Direction < 0) spread = 1.0 - spread; // 3. 用扩散距离加权阈值:中心优先溶解 float localThreshold = lerp(_DissolveAmount, _DissolveAmount * 0.2, spread); // 4. 噪声采样 float noise = tex2D(_NoiseMap, TRANSFORM_TEX(i.uv, _NoiseMap)).r; // 5. 溶解判断量:大于0说明已经溶解 float dissolveValue = noise - localThreshold; // 6. 边缘光遮罩:越靠近溶解边界越亮 float edgeMask = smoothstep(-_EdgeWidth, 0, dissolveValue); // 7. 原图采样并混合边缘颜色 fixed4 col = tex2D(_MainTex, i.uv); float3 finalRGB = lerp(col.rgb, _EdgeColor.rgb, edgeMask * _EdgeStrength); // 8. 用alpha做平滑裁切,替代clip,避免2D图片锯齿 float alpha = col.a * saturate(-dissolveValue / max(_FadeWidth, 0.0001)); return fixed4(finalRGB, alpha); } ENDCG } } FallBack "Sprites/Default" }第1步到第3步是扩散逻辑。先取像素到中心点的距离,除以扩散范围,得到0到1的扩散因子。然后拿这个因子把全局溶解进度和“0.2倍进度”做插值。为什么选0.2而不是0?如果边缘阈值也是完整的_DissolveAmount,那么当进度到1的那一刻,所有区域会同时到达溶解临界,扩散感会在一瞬间被“抹平”,看不清前锋线。给边缘留一个较低的阈值,能让内外之间始终保持先后顺序,扩散的层次感更明显。
第5步是溶解的核心判断量。当噪声值高于局部阈值,dissolveValue为正,像素被认为已溶解。当噪声值低于局部阈值,dissolveValue为负,像素保留。这个正负号后续既用来控制alpha,也用来控制边缘光的强弱。
2.3 边缘光加色的混合细节
很多溶解效果会在边界上叠加一圈亮色,模拟燃烧、腐蚀或能量消散的视觉提示。这个边缘光不能用简单的“接近边界就亮”来做,否则会把整张图内部全部染上色。
我在第6步用smoothstep从 -_EdgeWidth 到 0 做了一个渐变。当dissolveValue远小于0,也就是像素还在图片内部深处时,edgeMask为0,颜色不受影响。当dissolveValue接近0,也就是像素正好在溶解边界附近时,edgeMask接近1,边缘色完全替换原色。当dissolveValue大于0,像素已经不可见,edgeMask是1也没关系,因为alpha已经归零了。
这个渐变的好处是边缘光的宽度可以通过 _EdgeWidth 一个参数控制,而且是连续过渡的,不会出现一条突兀的硬边。如果想让边缘色只在非常窄的窄带上出现,把 _EdgeWidth 调到0.01左右即可;想要火焰燃烧那种宽光晕,调到0.08以上。
3. 在Unity里落地:材质、噪声图和C#控制脚本
3.1 噪声图从哪来:两种靠谱途径
噪声图是溶解边缘形态的“性格”所在。不同的噪声图,出来的溶解感觉完全不同:细碎的白噪点会让图片像被虫蛀一样一点点消失,平滑的云纹会让边缘像烧纸一样蔓延,大块的柏林噪声碎块感更强。
第一种途径是写一个编辑器脚本自动生成柏林噪声纹理,稳定可控且不需要依赖外部资源。在Assets目录下放一个Editor脚本,菜单栏里点击一下就会生成一张512x512的噪声图并自动导入工程:
using UnityEditor; using UnityEngine; using System.IO; public static class NoiseMapGenerator { [MenuItem("Tools/Generate Dissolve Noise")] public static void Generate() { int size = 512; Texture2D tex = new Texture2D(size, size, TextureFormat.RGBA32, false); float scale = 6f; for (int y = 0; y < size; y++) { for (int x = 0; x < size; x++) { float nx = x / (float)size * scale; float ny = y / (float)size * scale; float value = Mathf.PerlinNoise(nx, ny); value = Mathf.Clamp01(value + Random.Range(-0.08f, 0.08f)); tex.SetPixel(x, y, new Color(value, value, value, 1f)); } } tex.Apply(); string path = "Assets/DissolveNoise.png"; File.WriteAllBytes(path, tex.EncodeToPNG()); AssetDatabase.ImportAsset(path); TextureImporter importer = AssetImporter.GetAtPath(path) as TextureImporter; if (importer != null) { importer.textureType = TextureImporterType.Default; importer.wrapMode = TextureWrapMode.Repeat; importer.filterMode = FilterMode.Bilinear; importer.sRGBTexture = false; importer.SaveAndReimport(); } AssetDatabase.Refresh(); Object.DestroyImmediate(tex); } }注意生成脚本里设置了 sRGBTexture = false。这一点在Linear颜色空间的项目里格外重要:噪声图本质是数据图,不是给人看的颜色图,如果被当作sRGB纹理采样,GPU会把它从Gamma空间转成线性空间,噪声数值发生偏移,导致溶解阈值不准确,看起来进度总是“卡”在某个位置然后突然跳变。把sRGB关掉,噪声值就能原样参与计算。
第二种途径是直接用Photoshop或者免费的在线噪声生成器做一张灰度云纹图。推荐用“差值云彩”滤镜叠加几次,得到比较自然的块状过渡。导入Unity后别忘了把Wrap Mode设为Repeat,Filter Mode设为Bilinear。如果生成图尺寸较小,建议把压缩格式改为RGBA32或关闭压缩,避免压缩算法破坏灰度值。
3.2 C#控制脚本:进度、中心点、随机参数
Shader只是静态的计算规则,真正让溶解动起来的是外部脚本。很多新手会直接改共享材质或创建材质实例,这里有一个性能上的坑:如果直接修改sharedMaterial,会把所有使用同材质的物体全部改掉;如果直接修改material,Unity会为当前渲染器创建一份材质实例,持续累积会造成内存压力,而且没法在多个物体间合批。
正确姿势是用MaterialPropertyBlock。它专门用来给单个渲染器传属性,不改变共享材质,不影响其他物体。代码如下:
using System.Collections; using UnityEngine; [RequireComponent(typeof(SpriteRenderer))] public class DissolveEffect : MonoBehaviour { public Texture2D noiseMap; public float duration = 1f; [Range(0f, 1f)] public float startProgress = 0f; public AnimationCurve progressCurve = AnimationCurve.EaseInOut(0f, 0f, 1f, 1f); public Vector2 centerUV = new Vector2(0.5f, 0.5f); public Color edgeColor = new Color(1f, 0.4f, 0.1f, 1f); private SpriteRenderer sr; private MaterialPropertyBlock mpb; private float progress; private void Awake() { sr = GetComponent<SpriteRenderer>(); mpb = new MaterialPropertyBlock(); // 如果材质用的是我们的shader,可以在这里加载 // 否则请在Inspector里手动把材质拖到SpriteRenderer上 } public void Play() { StopAllCoroutines(); sr.enabled = true; StartCoroutine(PlayRoutine()); } private IEnumerator PlayRoutine() { float timer = 0f; progress = startProgress; ApplyProgress(progress); while (timer < duration) { timer += Time.deltaTime; float curveValue = progressCurve.Evaluate(timer / duration); progress = Mathf.Lerp(startProgress, 1f, curveValue); ApplyProgress(progress); yield return null; } ApplyProgress(1f); sr.enabled = false; } public void ApplyProgress(float value) { progress = Mathf.Clamp01(value); sr.GetPropertyBlock(mpb); mpb.SetFloat("_DissolveAmount", progress); mpb.SetVector("_Center", new Vector4(centerUV.x, centerUV.y, 0f, 0f)); mpb.SetColor("_EdgeColor", edgeColor); if (noiseMap != null) mpb.SetTexture("_NoiseMap", noiseMap); sr.SetPropertyBlock(mpb); } }这个脚本在Awake里初始化SpriteRenderer和PropertyBlock,在协程里按AnimationCurve的节奏推进进度,最终溶解完成后把SpriteRenderer关掉,省去透明像素的重复绘制。中心点默认在图片正中央,如果想让溶解从角色胸口、剑尖或者任意一点开始,直接在Inspector里改centerUV即可。
3.3 调参技巧:一组能直接用的初始数值参考
参数不是越多越好,而是要形成一套“默认好看”的配置。我实际项目里常用的初始值如下,可以直接照抄再按风格微调:
| 参数 | 数值 | 使用说明 |
|---|---|---|
| _DissolveAmount | 0~1 | 脚本驱动,不需要手动调 |
| _EdgeWidth | 0.05 | 中等宽度的边缘光,配合红色系很出效果 |
| _EdgeStrength | 1~1.5 | 想突出燃烧感调高,想低调调低 |
| _Center | (0.5,0.5) | 默认中心,改为(0,1)可以从左上角烧起 |
| _SpreadRange | 0.7 | 扩散范围越大,前锋越宽;越小越凝成“一团” |
| _Direction | 1 | 内向外扩散最适合死亡消散 |
| _FadeWidth | 0.02 | 羽化宽度,过大会让图片“化掉”,过小则恢复锯齿 |
调参有一个顺序问题:先定噪声图,再调扩散,最后调边缘光。噪声图决定整体碎块形态,扩散决定溶解的路径,边缘光只是锦上添花。幅度上,_FadeWidth 和 _EdgeWidth 对视觉效果的影响最大,从0.01起步每次加0.005,慢慢推到合适值。_SpreadRange 想调出“先从中心点突然爆开一小块,再缓缓扩散”的感觉,可以配合动画曲线把前0.2秒的duration压缩得很短,后面放长。
4. 实操中踩过的坑:锯齿、排序、性能与兼容性
4.1 2D Sprite的锯齿问题与透明度处理
很多3D溶解Shader里都用clip函数直接丢弃片元。clip在3D模型上问题不大,因为模型有厚度和光照,边缘观感粗糙一点也说得过去。但2D Sprite是平面的,边缘锯齿会被放得很明显,尤其当Sprite经过旋转或缩放时,硬clip边缘会像被劣质橡皮擦擦过一样,全是狗牙。
我的做法是放弃clip,用alpha来控制。在片元着色器最后一步,通过saturate(-dissolveValue / _FadeWidth)计算出0到1的透明度系数,再乘上原图alpha。dissolveValue越接近0,透明度越低;离边界越远,越不透明。这样边缘自动形成羽化过渡,_FadeWidth就是羽化宽度。
如果项目美术就是想要清晰锐利的溶解边缘,不喜欢糊成一片,可以把 _FadeWidth 设置成0.001,羽化窄到肉眼几乎看不见,又比clip多了一层抗锯齿的过渡。这是两全其美的做法。
4.2 透明排序和材质实例化的坑
2D项目里透明排序是个老话题。Shader里如果忘了ZWrite Off,半透明Sprite之间会出现互相遮挡,明明A在B前面,却被B盖住。另一个容易踩的是Queue标签,如果Queue是Geometry或其他不透明队列,SpriteRenderder的渲染顺序会乱掉,溶解了一半的图片可能被背景物体盖住。所以SubShader里的Tags三件套必须写全。
材质实例化方面,用MaterialPropertyBlock传参数确实能避免产生材质实例,但它也会打断合批。如果场景里有100个敌人同时播放溶解,每个敌人的进度不同,PropertyBlock属性不同,引擎没办法把它们合并成一个批次。不同进度意味着不同透明度,本来合批意义也不大。但如果只是少数几个敌人偶尔飘散,完全不用操心。如果要做大量对象的群体溶解,建议把进度计算放到顶点色或者总时间偏移里,让Shader从全局时间获取阶段值,减少MPB差异。
还有一个值得注意的点:脚本里如果直接sr.material = new Material(shader),每次Awake都会创建一个新材质。美术改了一遍材质颜色,运行后又恢复默认,排查半天找不到原因,多半就是这里。正确做法是在编辑器的Prefab上指定材质,脚本只负责用MaterialPropertyBlock传运行时变化的参数。
4.3 移动端性能与颜色空间的坑
溶解Shader的运算量本身不大:片元里两次纹理采样、一次距离计算、一次smoothstep,移动端低端机也扛得住。注意不要为了一时方便叠加多个噪声采样,也别在一个Shader里写十几个if分支。我这个版本里有一个if (_Direction < 0),实际GPU编译时会把它编译成条件执行指令,开销几乎为0。如果你很在意分支,也可以用spread = lerp(spread, 1.0 - spread, step(0.0, -_Direction))去掉if,不过多数移动GPU对简单的分支处理并不慢。
线性颜色空间下,如果噪声图没有关闭sRGB,会出现噪声值被重新映射的坑,前面提过。边缘光颜色也会变:在Gamma空间里调好的橙色 (1,0.5,0.15),到了Linear空间会变得更暗更黄。解决方式有两个,一是在Linear项目里重新调EdgeColor,二是写代码时用mpb.SetColor传过去,Unity会按当前颜色空间自动转换,只要在属性面板观察到的就是最终效果即可。
5. 效果扩展:从一个Shader变成一套玩法表现
5.1 反转进度:从消散变出现
这个Shader不仅能做消散,把进度从1降到0,就是一张图从碎片中慢慢聚合出来的“出现”效果。配合反转的AnimationCurve,可以让碎片在0.3秒内快速聚拢,再花0.5秒缓慢稳定,视觉节奏很舒服。扩散方向也能配合:从外向内聚合时,物体像从虚空中被“捏”出来;从内向外聚合时,像一团能量膨胀成实体。这两种情绪是完全不同的,策划可以根据演出需要切换。
5.2 多阶段溶解:火焰灼烧、冰封碎裂、传送门
换一张噪声图,整个效果的风格就会大变。火焰灼烧适合用高频噪声图,碎块小、边缘窄、边缘色用橙色到亮黄渐变;冰封碎裂需要大块的平滑柏林噪声,边缘色用淡蓝白,位置固定在中线上,营造裂纹蔓延感;传送门效果适合大范围云纹噪声,边缘色用紫色或青色,再配合粒子系统发射一些微小碎屑。
一个更进阶的做法是让边缘位置在溶解过程中实时变化。给Shader增加一个UV扭曲参数,在采样噪声图之前,用另一张低频噪声对UV做偏移:
float2 distortedUV = i.uv + (noise - 0.5) * _DistortStrength; float noise2 = tex2D(_NoiseMap, distortedUV).r;这样做出来的溶解边缘会像被风吹动的火苗一样扭动,而不是呆板的静态形状。代价是多一次噪声采样,移动端要考虑性能余量。
5.3 多张图片同时溶解与顺序控制
如果角色由多个Sprite组合而成,比如身体、披风、武器分开,想实现“先溶解衣服,再溶解身体”,可以在C#脚本里给每个部件设置不同的延迟启动时间,用一个总的DissolveManager统一管理。每个组件各自维护自己的进度,通过事件回调通知Manager当前阶段完成。这样既能保持各部件参数独立,又不需要写复杂的协程嵌套。
要注意多个部件同时溶解时,粒子特效、音效的触发时间要和进度对齐。我的经验是把音效放在进度到0.2和0.8两个节点上,前者是开始碎裂的提示,后者是接近消失的高潮,比单一节点更有层次感。
最后再说一个我实际开发中的体会:做溶解效果,技术上的门槛反而不是Shader语法,而是对噪声图和进度曲线的“手感”把控。同一个Shader,换一张噪声图、改一下AnimationCurve的形状,出来的气质完全不一样。建议第一次用这个Shader时,先花半小时批量生成几种不同风格的噪声图,在场景里摆一排角色同时播放不同参数配置,肉眼对比选择。这个工作量很值得,因为溶解效果通常出现在角色退场、怪物死亡的焦点时刻,玩法的“爽感”往往就藏在这零点几秒的观感里。