做项目的时候经常要衰落场景、做死亡消散、拆解保护罩,或者让怪物被“烧成灰”,这时候消融效果就是最顺手的那一类Shader方案。所谓消融(Dissolve),核心就一句话:让物体表面按某种规则从“完整”到“消失”,中间带一圈高亮的过渡边缘,看起来就像被一点点烧掉或溶解掉。这个效果我在Unity里反复写过很多次,从最早Built-in管线里的Surface Shader,到后面URP里用Shader Graph搭,再到纯手写HLSL,踩过不少坑,也攒了一些能直接用的套路。这篇就围绕【动态着色】这个关键词,把Unity实现消融效果的完整思路和可复现代码整理出来,适合正在做特效、战斗表现或场景交互需求的Unity开发者参考。
1. 消融效果的底层逻辑与方案选型
1.1 三种常见实现方案的对比
先把方案摊开看。消融效果的“消失”表现,业界主流做法有三条路线。
第一种是透明度混合(Alpha Blend),也就是把材质的透明度从1渐变成0。这个方案实现起来最简单,Adjust一下Alpha值就行。但问题也很致命:消融边缘没法做出“燃烧高亮”的效果,而且半透明物体在处理遮挡和深度关系时排序很容易乱,物体和场景穿插时穿帮概率极高。我试用过一次,给机械敌人做粉碎消散,结果身体各部件互相透视,立刻放弃。
第二种是顶点动画加粒子替代,也就是把模型顶点沿法线方向往外推,同时播放一个粒子爆发效果,视觉上像“爆开”。这个方案的优点是动态感很强,适合爆炸、碎散类的表现。但它本质上不是“消融”,而是“碎裂”,而且需要对模型顶点数据做额外处理,对美术资产的规格有要求。
第三种就是今天要重点展开的:保留颜色贴图的噪声裁剪(Noise Clipping)方案。核心思路是利用噪声图的灰度值作为“消融掩码”,通过一个随时间增长的阈值参数,把掩码低于阈值的像素直接裁掉(clip/discard)。因为噪声图是不规则的,所以边缘不是一条直线,而是参差不齐的“烧蚀”状态,再配合smoothstep在阈值附近做渐变,就能渲染出一圈烫红的边缘。这是商业项目中用得最多的方案,也是动态着色在消融效果中最典型的体现。
1.2 为什么Shader裁剪方案最适合动态着色
裁剪方案能长期胜出,是因为它在“表现力”和“可控性”之间取得了最好的平衡。
先说表现力。噪声图本身就是灰度渐变加随机斑驳的形态,采样之后天然具备自然的高位与低位区域。随着阈值从0增加到1,被裁掉的区域不是均匀收缩,而是沿着噪声里那些不规则的“岛”和“沟壑”逐步扩散,形成类似烧焦、锈蚀、能量瓦解的随机感。再加上边缘发光、灼烧颜色渐变,视觉层次非常丰富。
再说可控性。整个消融过程的一切变化都集中在一个参数上——阈值(_DissolveAmount)。从脚本侧传入0到1的数值,就能精确控制“消融到哪一步”。配合不同的噪声贴图和边缘色,能刷出火焰燃烧、冰霜溶解、沙尘风化、次元剥离等完全不同的风格。这套思路做一次,后面不管换哪种表现,改参数就行。
而且这个方案对性能很友好。片元着色器里就是一次tex2D采样加一次clip,计算量几乎可以忽略不计。对移动端也很友好,我从PICO一体机到主流手机都实测过,没有任何压力。
2. 核心原理拆解:噪声决定细节,阈值控制节奏
2.1 噪声贴图的选择:Perlin噪声还是Voronoi图
这一步很关键,因为噪声图直接决定消融边缘的“性格”。
Perlin噪声(也就是常说的柏林噪声)的特点是平滑自然、连续渐变,用来做火焰燃烧、物体腐蚀这类有机过程的消融效果非常合适。Perlin噪声的灰度值会形成类似云朵一样的斑块,消融时边缘会呈现棉絮状的参差感,烧焦的过渡也比较柔和。
Voronoi图(细胞噪声/Worley噪声)的特点是一格一格的晶胞结构,边缘分明,消融时会出现“一块一块脱落”的视觉,适合做冰雪融化、蜂巢结构、能量块碎裂这类偏向几何风格的效果。
实际使用中,如果Art那边没有现成的噪声贴图,可以在网上找灰度噪声素材,或者直接拿Photoshop里的“云彩滤镜”和“铜板雕刻”滤镜顺手生成。有一点要记住:贴图导入Unity后,要确认勾选了Alpha Is Transparency、Wrap Mode设置为Repeat,同时把sRGB采样关掉(尤其是在URP里如果当Mask用,建议直接Unpack后在Shader内部处理,避免颜色空间坑)。如果不想依赖外部贴图,也可以直接在Shader里用代码生成噪声,比如采三层不同频率的Perlin噪声叠加,但这种做法会多一些计算量,移动端谨慎使用。
2.2 clip()与smoothstep协同:边缘渐变的实现细节
Shader裁剪的核心API就是clip()函数。在HLSL里,如果传入clip()的参数小于0,这个片元会被直接丢弃,完全不可见。这是实现消融的基石。
但单纯的clip裁剪只能得到“有/无”的硬边界,没法得到好看的过渡边缘。所以要在阈值附近做两步处理。
第一步,用smoothstep把阈值附近生成一个渐变量,作为边缘光强。假设当前片元的噪声值是noise,消融进度是_DissolveAmount,我们可以这样计算:
float dissolveLine = smoothstep(_DissolveAmount - _EdgeWidth, _DissolveAmount + _EdgeWidth, noise);这个dissolveLine在噪声值远小于阈值时为0,远大于阈值时为1,中间会有一个平滑过渡带。用1减去它再乘以边缘色,就得到了精确的边缘光。
第二步,注意边缘光的颜色一般要做两层渐变。最外圈是烧红/烧白的高亮色,往里一点是暗红或暗紫色。这样视觉上会有“由外到内从炽热到暗淡”的温度感,非常像真实燃烧。
我这边常用的做法是:
float edgeMask = 1.0 - dissolveLine; float glow = pow(edgeMask, 2.0); // 外层更亮 float core = pow(edgeMask, 4.0); // 内核范围更收 color.rgb += _EdgeColor.rgb * glow * _EdgeIntensity; color.rgb += _BurnColor.rgb * core * _BurnIntensity;这样的多层边缘叠加比单一smoothstep要立体得多,燃烧边缘的高光和暗部层次都在。
2.3 动态着色:从脚本侧控制消融进度
“动态着色”的“动态”两个字,核心就在于这个参数是怎么随时间变化的。
最直接的方式是在C#脚本里每帧更新Shader属性。但这里有个非常关键的性能细节:不要直接调用material.SetFloat(),因为这个方法会创建材质实例,可能造成额外的Draw Call和GC。更稳妥的做法是用MaterialPropertyBlock,或者直接把材质设为可复用的实例并只在需要时更新。
MaterialPropertyBlock的好处是它允许同一个材质球、不同物体各自维护一份参数副本,不会破坏合批。比如场景里有100个敌人,都能挂同一个材质,然后通过MaterialPropertyBlock各自控制消融进度,性能表现非常好。对于粒子系统伴随、残骸掉落等复杂交互,也是推荐用这个方案,因为它把“状态”和“材质”解耦了。
C#侧的控制逻辑,简单来说就是一个协程,从0增加到1,同时控制消融阈值和切换碰撞体:
public IEnumerator PlayDissolve(float duration) { float t = 0f; while (t < 1f) { t += Time.deltaTime / duration; _mpb.SetFloat("_DissolveAmount", Mathf.Clamp01(t)); // 每帧把参数应用给Renderer _renderer.SetPropertyBlock(_mpb); yield return null; } }这里的核心思路是:真正渲染时Shader只负责读取参数并执行裁剪,变化过程完全由业务侧管理。这样脚本层可以做很多扩展,比如消融到50%时触发怪物死亡音效、90%时触发掉落物生成等。
3. 完整实操:从Shader到场景配置一键复现
3.1 手写Shader的完整代码与逐行解读
下面给出一份标准的、可在Built-in管线和URP下都能使用的完整Shader代码。我习惯写得结构化一些,这样后来维护的人一看就懂。
Shader "Custom/DissolveEffect" { Properties { _MainTex ("Albedo (RGB)", 2D) = "white" {} _NoiseMap ("Noise Map", 2D) = "white" {} _DissolveAmount ("Dissolve Amount", Range(0,1)) = 0 _EdgeWidth ("Edge Width", Range(0,0.5)) = 0.05 _EdgeColor ("Edge Color", Color) = (1,0.4,0.2,1) _BurnColor ("Burn Color", Color) = (0.5,0,0,1) _EdgeIntensity ("Edge Intensity", Range(0,5)) = 2 } SubShader { Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" } Pass { HLSLPROGRAM #pragma vertex vert #pragma fragment frag #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl" #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl" struct Attributes { float4 positionOS : POSITION; float2 uv : TEXCOORD0; }; struct Varyings { float4 positionHCS : SV_POSITION; float2 uv : TEXCOORD0; }; TEXTURE2D(_MainTex); SAMPLER(sampler_MainTex); TEXTURE2D(_NoiseMap); SAMPLER(sampler_NoiseMap); CBUFFER_START(UnityPerMaterial) float _DissolveAmount; float _EdgeWidth; float4 _EdgeColor; float4 _BurnColor; float _EdgeIntensity; CBUFFER_END Varyings vert(Attributes IN) { Varyings OUT; OUT.positionHCS = TransformObjectToHClip(IN.positionOS.xyz); OUT.uv = IN.uv; return OUT; } half4 frag(Varyings IN) : SV_Target { float noise = SAMPLE_TEXTURE2D(_NoiseMap, sampler_NoiseMap, IN.uv).r; clip(noise - _DissolveAmount); float dissolveLine = smoothstep( _DissolveAmount - _EdgeWidth, _DissolveAmount + _EdgeWidth, noise); float edgeMask = 1.0 - dissolveLine; float glow = pow(edgeMask, 2.0); half4 albedo = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, IN.uv); half3 finalColor = albedo.rgb; finalColor += _EdgeColor.rgb * glow * _EdgeIntensity; finalColor += _BurnColor.rgb * pow(edgeMask, 4.0) * 1.5; return half4(finalColor, 1.0); } ENDHLSL } } }代码不复杂,但有几个点得解释清楚。
首先,为什么要用HLSL而不是Surface Shader?Surface Shader在URP下兼容性越来越差,而HLSL可以同时兼容Built-in和URP,只要把光照相关代码替换为对应的Library即可。这份Shader里没有做复杂光照,只保留最朴素的纹理与边缘色。如果要做PBR光照,可以在URP里加Lighting.hlsl的引用,再把BRDF函数接入。
其次,注意Clip指令放在了最前面,噪声值小于阈值的片元直接丢弃。如果后续还想让消融边缘的“半透明烟尘”更自然,这里可以扩展为:
float alpha = saturate((noise - _DissolveAmount) / (_EdgeWidth * 0.5)); clip(alpha - 0.5); // 在返回时用alpha混合但这样会切换到透明队列,会带来排序问题。老实说,不透明队列配合裁剪已经能覆盖90%的项目需求,透明烟尘这种细节需求一般靠额外粒子系统补,不要硬塞进材质里,反而好控制。
3.2 场景搭建与参数调试步骤
第一步,准备模型和贴图。Unity内置的Cube、Sphere都可以测试,如果要做人形怪物消融,请务必有一套装备贴图规范的模型,UV不要重叠,否则消融区域会拉伸。
第二步,创建材质,把DissolveEffect Shader拖上去。这里有个我踩过坑的地方:噪声贴图的Wrap Mode改为Repeat,如果默认Clamp,在边缘处会出现发丝状亮线。
第三步,调整核心参数。初始建议_DissolveAmount设为0,_EdgeWidth 0.05,_EdgeColor偏橙红色(1,0.45,0.2,1),_BurnColor偏暗红(0.7,0.1,0,1),_EdgeIntensity 2。然后在场景里滑动数值观察变化。
第四步,挂脚本。写一个简单的控制脚本,设定按下空格键播放消融。完整控制脚本如下:
using System.Collections; using UnityEngine; [RequireComponent(typeof(Renderer))] public class DissolveController : MonoBehaviour { [SerializeField] private float duration = 1.5f; [SerializeField] private AnimationCurve curve = AnimationCurve.EaseInOut(0, 0, 1, 1); private Renderer _renderer; private MaterialPropertyBlock _mpb; private static readonly int DissolveAmount = Shader.PropertyToID("_DissolveAmount"); private void Awake() { _renderer = GetComponent<Renderer>(); _mpb = new MaterialPropertyBlock(); // 初始化时同步一下当前值,防止编辑器调整后场景值失真 _renderer.GetPropertyBlock(_mpb); } [ContextMenu("Play Dissolve")] public void Play() { StartCoroutine(PlayDissolveRoutine()); } private IEnumerator PlayDissolveRoutine() { float t = 0f; while (t < 1f) { t += Time.deltaTime / duration; float value = curve.Evaluate(Mathf.Clamp01(t)); _mpb.SetFloat(DissolveAmount, value); _renderer.SetPropertyBlock(_mpb); yield return null; } } }注意脚本里的AnimationCurve。这个节点很有意思,不是直接线性上升,而是用EaseInOut曲线让消融开始时和收尾时都更柔和,视觉上会比“全程匀速”更高级。你也可以调整出“前期缓慢燃烧,后期瞬间崩解”的节奏。
第五步,处理阴影。如果物体在消融过程还需要接收和投射阴影,要注意Unity的ShadowCaster Pass不会自动继承这个裁剪逻辑。也就是说,可能出现“物体已经消融了,但场景里还留下一个完整的黑色阴影”的鬼影现象。解决办法是给SubShader再加一个ShadowCaster Pass,同步执行clip。
Pass { Name "ShadowCaster" Tags { "LightMode" = "ShadowCaster" } HLSLPROGRAM #pragma vertex vertShadow #pragma fragment fragShadow #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl" TEXTURE2D(_NoiseMap); SAMPLER(sampler_NoiseMap); CBUFFER_START(UnityPerMaterial) float _DissolveAmount; CBUFFER_END struct Attributes { float4 positionOS : POSITION; float2 uv : TEXCOORD0; }; struct Varyings { float4 positionHCS : SV_POSITION; float2 uv : TEXCOORD0; }; Varyings vertShadow(Attributes IN) { Varyings OUT; OUT.positionHCS = TransformObjectToHClip(IN.positionOS.xyz); OUT.uv = IN.uv; return OUT; } half4 fragShadow(Varyings IN) : SV_Target { float noise = SAMPLE_TEXTURE2D(_NoiseMap, sampler_NoiseMap, IN.uv).r; clip(noise - _DissolveAmount); return 0; } ENDHLSL }这个Pass看起来体积不小,但模板固定,复制粘贴改个名就行。如果忘写了,消融效果肯定会露怯。
3.3 进阶:多噪声叠加、方向性消融与顶点色局部控制
基础版能跑通之后,想做得更“贵”一点,可以从三个方向升级。
第一个方向是多噪声叠加。单张噪声图容易出现重复感,尤其是大面积模型上,Tile到一定程度后纹理周期非常明显。这时候可以采样第二张噪声,用不同Tiling和偏移动态合成:
float noiseA = SAMPLE_TEXTURE2D(_NoiseMap, sampler_NoiseMap, IN.uv * 1.0).r; float noiseB = SAMPLE_TEXTURE2D(_NoiseMap, sampler_NoiseMap, IN.uv * 3.7 + _Time.y * 0.05).r; float noise = saturate(noiseA * 0.7 + noiseB * 0.3);注意第二张要加Time做偏移,这样效果就会“活”起来,边缘一直在细微爬动,很像真实的火舌舔舐。
第二个方向是方向性消融。比如想做出“从脚底往头顶烧上去”的效果,可以把模型高度作为一个附加因子。用世界坐标Y轴减去阈值的一部分:
float heightMask = saturate((worldPos.y - _DissolveAmount * 3.0) * 2.0); noise *= heightMask;这样物体从底盘开始消失,往上升的方向蔓延,适合做传送门、复活石这类效果。
第三个方向是顶点色局部控制。美术可以在需要“先烧的地方”把顶点色涂黑,Shader里用顶点色乘噪声值,这样同样的阈值条件下,不同区域的消融先后顺序就有了艺术化控制。这个做法在高级特效资产里非常常见,强烈推荐。
4. 常见问题与排查技巧实录
4.1 消融边缘出现“锯齿闪烁”或“颗粒噪点”
这个问题很多新手都会碰到,表现为边缘不干净,甚至出现像电视雪花一样的小点。
排查思路有三个方向。
第一,确认噪声贴图的压缩格式和过滤模式。我建议把噪声贴图的Generate Mip Maps关掉,压缩格式在移动端可以直接用RGBA Compressed等,但不要用带Crunch的压缩,因为Crunch压缩出来的高频细节会有严重失真。同时把Filter Mode设为Bilinear或Trilinear,Point过滤会让边缘特别硬。
第二,检查颜色空间。如果项目使用Linear(线性颜色空间),半透明或渐变采样可能会出现Gamma校正错误,尤其是在边缘渐变色上特别明显。可以在Shader里对边缘色做Linear转换,或者直接把采样噪声的sRGB采样关闭。URP里我习惯在属性标记为[NoScaleOffset]然后在frag里手动处理。
第三,如果是在移动端,确保没有开启不必要的MSAA与后处理同时叠加,有时候MSAA配合clip会在边缘产生噪点,需要把RenderType改为TransparentCutout并开启AlphaToMask。
4.2 物体消融了但阴影还在
这是最高频的问题之一,原因前面已经提过:默认的ShadowCaster Pass不知道消融逻辑。
解决方案就是在Shader里补一个并发执行clip的ShadowCaster Pass,代码模板上面给了,直接加进去。加完之后在Lighting窗口重新Bake或实时阴影都会同步更新。
还有一个小坑:如果模型是SkinnedMeshRenderer(比如怪物),阴影裁剪逻辑同样适用,但要注意不要把噪声图采样UV和动态骨骼的UV弄混,SkinnedMesh的UV在动画形变后不会变,放心用。
4.3 粒子系统和消融不同步
很多项目希望消融到一半时,物体“炸开”出火焰粒子;到结尾时,残骸掉落。这里最容易翻车的是:粒子已经开始播了,消融才刚过1/3。
解决方案是在C#侧把消融过程抽象成“事件点”,而不是“一个参数从头到尾”。具体做法是写一个事件回调:
public System.Action<float> OnDissolveProgress; public System.Action OnDissolveHalf; // 阈值50%触发 public System.Action OnDissolveComplete; // 阈值100%触发然后在协程里每帧检查Progress是否跨越了特定阈值,触发对应事件。这样粒子、音效、物理掉落都能与消融进度精确对齐。我做得最多的一套组合拳就是:消融进度到60%时,模型关闭碰撞体、开启布娃娃模拟;到80%时触发爆炸粒子;100%时销毁GameObject。整套流程下来观感非常顺滑。
4.4 消融效果在URP和移动端适配的几个注意点
如果项目已经切到URP,使用上面代码时要注意全部使用HLSL,不要混用CGPROGRAM。URP下如果要做Lit光照效果,需要额外引入UniversalInput.hlsl和Lighting.hlsl。这里我只给了最朴素的Unlit版本,性能最优,也足够应对大多数消融需求。
移动端适配更要注意带宽。即使噪声图只是单通道,Android平台也会按RGBA加载,一倍内存开销。如果很在意,可以用ASTC压缩格式并降低噪声图分辨率,1024x1024完全够用,不要动不动上4096。
还有一点,如果后期加了Bloom后处理,消融边缘的炽热色会有很明显的辉光效果,视觉上提升很大。建议在URP Volume里开Bloom,阈值调稍高一点,只让边缘高亮部分发光,这样烧灼感会特别有质感。
5. 项目应用评估与扩展建议
5.1 典型应用场景的具体搭配
消融效果的适用面非常广。我整理了几种最常遇到的场景组合,方便你直接对着选参数。
| 场景 | 噪声图风格 | 边缘色 | 灼烧色 | 额外细节 |
|---|---|---|---|---|
| 火焰燃烧 | Perlin噪声 | (1.0, 0.5, 0.1) 橙红 | (0.4, 0.02, 0) 深红 | 开Bloom+粒子火焰 |
| 冰霜溶解 | Voronoi噪声 | (0.7, 0.9, 1.0) 淡蓝 | (0.1, 0.2, 0.4) 冰蓝 | 边缘加半透明冰晶 |
| 敌人死亡消散 | Perlin噪声+顶点色 | (1.0, 0.8, 0.4) 金色 | (0.2, 0, 0.3) 紫色 | 尾部拖光带 |
| 次元传送门 | Voronoi高频+流动 | (0.2, 0.9, 1.0) 青色 | (0, 0.1, 0.4) 深蓝 | UV旋转+方向性消融 |
这里面最出效果的是“冰霜溶解”搭配半透明冰晶粒子,边缘如果再加上一点法向扰动,看起来就像真的被冰冻后粉碎一样。做朝向物体的镜头冲击也很有氛围。
5.2 性能评估:一块钱成本换高级表现
从性能消耗看,消融Shader几乎是性价比之王。片元阶段只有两次纹理采样(主纹理加噪声),一次clip,两次pow和若干加法。在中低端手机上,一个屏幕同时出现5-6个消融中的物体非常流畅,远低于半透明粒子叠加的负载。
但要注意合批问题。如果用MaterialPropertyBlock传递参数,合批基本不受影响;如果用了material.SetFloat让材质变成实例,每个实例都会有独立的Shader Property块,强行打断合批。所以再次强调:多人/多物体场景直接上MaterialPropertyBlock方案,这个习惯越早养成越好。
5.3 跟Unity新版本特性的结合
现在Unity 6和URP 17已经普及,Shader Graph里也内置了Dissolve相关节点模板。但我个人的体会是,手写HLSL代码依然有不可替代的优势:逻辑透明、便于版本控制、不依赖巨型Shader资源,而且代码复用性极高。哪怕项目组里其他人习惯用Shader Graph,我建议也要保留一份手写Shader作为性能基线和兜底方案。
新版URP还支持了Render Graph和GPU Resident Drawer,这对手写Shader更友好,因为能更精确地控制Pass和CBUFFER布局。如果项目准备升级,把手写Shader都迁移到HLSL+CBS(Constant Buffer)结构,后续维护会很省心。
6. 写在最后:几个值得留住的实操习惯
做消融效果这几年下来,我最大的感触是:这个效果的入门门槛不高,但天花板完全取决于细节的讲究程度。
一个小技巧:给消融边缘的_EdgeColor做HDR颜色(URP里直接用Color属性上的HDR模式),配合Bloom后处理,不用加任何粒子,光靠材质颜色就能把“炽热感”烧出来。这是成本最低的提升手段,建议一开始就把边缘色设为(2,1,0.3,1)这种超亮值,而不是(1,0.5,0.1,1)。
另一个习惯:SV_Target输出时,边缘高亮区域的Alpha不要设成0,否则在移动端某些GPU上会触发隐藏的Alpha测试,反而降低性能。直接保持1.0,裁剪逻辑交给clip就好。
最后再分享一个排查思路:如果遇到效果没问题但性能突然暴降,先用Frame Debugger看看是不是因为Shader变体爆炸,ShadowCaster和主Pass各开了一套变体,加上多纹理、多平台宏,很容易膨胀。给Shader加#pragma multi_compile_instancing等优化指令,再配合Unity Shader Variant工具裁剪掉用不到的变体,性能就能稳回来。
希望这套拆解和代码能给你省点时间,把更多精力花在创意的打磨上。