Unity 2D图片溶解扩散Shader:噪声图、边缘光与扩散控制
做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写出来是给美术调参数用的所以属性面板够不够直观直接决定这个东西好不好用。我的参数设计如下参数类型作用_MainTex2DSprite图片由SpriteRenderer自动传入_NoiseMap2D噪声图决定溶解边缘的形状_DissolveAmountRange(0,1)溶解进度0为完整1为全部消失_EdgeWidthRange(0,0.2)边缘发光带的宽度_EdgeColorColor溶解边缘的燃烧色/描边色_EdgeStrengthRange(0,2)边缘色混合强度_CenterVector扩散中心点UV坐标空间_SpreadRangeRange(0,1)扩散范围控制溶解前锋从中心到边缘的跨度_DirectionFloat扩散方向1为内向外-1为外向内_FadeWidthRange(0,0.1)边缘羽化宽度越大越柔和_MainTex 上加了一个 [PerRendererData] 标记。这个标记很重要它告诉Unity这个属性是每个渲染器自己传数据的不要序列化在材质球里。SpriteRenderer默认就是用MaterialPropertyBlock传Sprite贴图的不加这个标记在部分Unity版本下会出现资源引用混乱或无法正常取图的问题。除了上面这些还要在Shader里写清楚透明渲染需要的状态。2D Sprite默认是透明混合的所以SubShader里必须设置Tags { QueueTransparent RenderTypeTransparent IgnoreProjectorTrue } 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 { QueueTransparent RenderTypeTransparent IgnoreProjectorTrue PreviewTypePlane } 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设为RepeatFilter Mode设为Bilinear。如果生成图尺寸较小建议把压缩格式改为RGBA32或关闭压缩避免压缩算法破坏灰度值。3.2 C#控制脚本进度、中心点、随机参数Shader只是静态的计算规则真正让溶解动起来的是外部脚本。很多新手会直接改共享材质或创建材质实例这里有一个性能上的坑如果直接修改sharedMaterial会把所有使用同材质的物体全部改掉如果直接修改materialUnity会为当前渲染器创建一份材质实例持续累积会造成内存压力而且没法在多个物体间合批。正确姿势是用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 GetComponentSpriteRenderer(); 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 调参技巧一组能直接用的初始数值参考参数不是越多越好而是要形成一套“默认好看”的配置。我实际项目里常用的初始值如下可以直接照抄再按风格微调参数数值使用说明_DissolveAmount0~1脚本驱动不需要手动调_EdgeWidth0.05中等宽度的边缘光配合红色系很出效果_EdgeStrength1~1.5想突出燃烧感调高想低调调低_Center(0.5,0.5)默认中心改为(0,1)可以从左上角烧起_SpreadRange0.7扩散范围越大前锋越宽越小越凝成“一团”_Direction1内向外扩散最适合死亡消散_FadeWidth0.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时先花半小时批量生成几种不同风格的噪声图在场景里摆一排角色同时播放不同参数配置肉眼对比选择。这个工作量很值得因为溶解效果通常出现在角色退场、怪物死亡的焦点时刻玩法的“爽感”往往就藏在这零点几秒的观感里。