消融效果是游戏里出场率特别高的一类动态着色表现,怪物死亡、场景坍塌、传送门开启、角色受伤消失,都能靠它做出很自然的过渡。很多朋友一看到Shader就发怵,其实Unity里实现一个可用的消融效果并不复杂,核心思路和代码量都很克制,这篇文章我就从最基础的原理开始,一步步带你做出一个能直接拖进项目的动态消融Shader,顺带把边缘发光、动态控制、性能优化这些坑一次讲清楚。
1. 整体设计与思路拆解
1.1 消融效果的核心原理
消融效果的本质,是让一个物体表面按照某种规则"局部消失"。如果把物体表面的每一个点都看成独立的像素,那么消融就是逐像素地决定"这个点还显示,还是不显示"。
想在Unlit或Surface Shader里删掉某个像素,Shader里有一个现成的武器——clip()函数。clip(x)的作用是:如果传入的x小于0,直接丢弃当前片元。换句话说,clip就是一道闸门,只要给每个像素算出一个数来判断"该不该被丢弃",消融就有了雏形。
但你总不能手动给模型上每个点写死"谁先消失",所以我们需要一张纹理作为"裁判"。最常用的是一张噪声纹理(Noise Map)或者叫扰动纹理,这张图上每个像素有一个灰度值。消融进度越高,灰度值低于阈值的像素就被裁掉,这样物体表面就会从低灰度区域开始融化、消失,形成一种无序的、不规则的自然溶解感。
1.2 为什么必须用噪声纹理,而不是普通贴图
有经验的开发者会直接用一张带噪点的灰度图,而不是用规则格子纹理。原因在于人类视觉对"规则"非常敏感。如果你用渐变图或规则条纹做消融,边缘会很整齐,一眼就能看出是程序生成的"假效果"。
噪声图的灰度值变化是随机的、湍流状的,这样算出来的消融边缘才会有破碎的、类似烧焦或者冰融化的感觉。这也是消融效果设计中最关键的一个"感觉来源"。
1.3 动态着色的整体逻辑
所谓"动态着色",本质上就是Shader中有一个对外暴露的参数,比如_Progress,表示当前的消融进度。这个值可以从0变化到1,0表示完整显示,1表示完全消失。我们在Unity脚本里每帧更新这个参数,Shader就会实时根据新的阈值去裁剪像素,这样就出现了动态溶解动画。
整体流程可以概括为:
- 准备Shader(包含噪声采样、阈值计算、边缘高亮)
- 准备一张噪声纹理
- 在脚本中驱动阈值变化
- 挂到任意MeshRenderer/ SkinnedMeshRenderer上
1.4 边缘高亮是怎么做出来的
如果只是单纯裁像素,消融过程会比较单调,弱化视觉冲击力。这时候可以在裁掉的边缘附近加一圈颜色,模拟"高温熔化发光"或者"能量侵蚀"的效果。
思路是:在Shader里采样噪声图之后,拿噪声值和阈值做比较。已经低于阈值的部分被裁掉,刚好在阈值附近的部分(噪声值比阈值高但高得不多)视为边缘区域,用lerp混合一个亮色上去。通过_EdgeWidth控制边缘带宽度,通过_EdgeColor控制边缘颜色。这样消融部分就会像被烧红的金属边缘一样,既提醒玩家"这里正在消失",又大幅提升画面品质。
2. 核心细节解析与实操要点
2.1 技术选型:Surface Shader还是Unlit Shader
Unity里写消融效果有两种很典型的路线:
- Surface Shader:适合需要完整光照响应的情况。比如角色皮肤、布料、金属等有明确光照模型的物体。
- Unlit / 自定义顶点片元Shader:适合UI特效、粒子贴片、强调颜色而不依赖场景光影的效果。
我的建议是,刚入门先从Unlit路径开始做验证,逻辑更清晰,逐行看代码不容易被Surface Shader的隐藏逻辑干扰。等技术熟悉了,再迁移到Surface Shader里加光照,或者直接在URP的Lit基础上改。
如果你用的是URP(Universal Render Pipeline,Unity 2019之后的默认渲染管线),写Shader时要特别注意Shader Lab里的Pass标签要声明"LightMode" = "UniversalForward",并且#include对应的Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl。老式Built-in管线的CGPROGRAM写法在URP下也能跑纯Unlit效果,但一旦涉及光照标准函数就会出问题。
2.2 Shader参数设计
一个完整可用的消融Shader应该暴露以下参数:
| 参数名 | 类型 | 作用 |
|---|---|---|
_MainTex | 2D | 主纹理,即物体的基础贴图 |
_NoiseMap | 2D | 噪声扰动图,决定消融区域分布 |
_Progress | Range(0,1) | 消融进度,0完整,1全部消失 |
_EdgeWidth | Range(0,0.5) | 边缘高亮宽度 |
_EdgeColor | Color | 边缘高亮颜色 |
核心的计算方式:
fixed noise = tex2D(_NoiseMap, i.uv).r; fixed edge = noise - _Progress; clip(edge); // 低于阈值裁掉然后边缘高亮用smoothstep(0, _EdgeWidth, edge)来取得边缘的渐变权重,再与基础颜色混合。
2.3 噪声图怎么选、怎么处理
很多项目卡在噪声图上。推荐使用Perlin噪声图,网上能找到很多免费的灰度噪声图资源,也可以自己用Photoshop生成:新建画布,填充黑白渐变杂色,然后执行滤镜-像素化-晶格化,效果就很接近消融噪点。
还有一种思路是用Substance Designer程序化生成一张比较"稠密"的Perlin噪声,放在工程的Assets/Textures目录里。有几个关键点:
- 尽量选灰度图,省去后续转换。
- 勾选sRGB(Texture Type设为Default即可),不需要法线贴图型。
- 压缩格式用ASTC或DXT5都可以,影响不大。
- Wrap Mode必须设为Repeat,防止UV采样到边缘时出现硬边。
- 如果物体是多个UV块拼合的超大场景,噪声图要配合y轴世界坐标,这时候可以在Shader中用
float3 worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;参与采样,可以额外避免UV拉伸导致的噪点分布不均。
2.4 在Built-in管线下完整Shader代码
在Built-in选择Shader大概长这样:
Shader "Custom/Dissolve" { Properties { _MainTex ("Albedo", 2D) = "white" {} _NoiseMap ("Noise Map", 2D) = "white" {} _Progress ("Progress", Range(0,1)) = 0 _EdgeWidth ("Edge Width", Range(0,0.5)) = 0.05 _EdgeColor ("Edge Color", Color) = (1,1,1,1) } SubShader { Tags { "RenderType"="Opaque" "Queue"="Geometry" } Cull Off Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include "UnityCG.cginc" sampler2D _MainTex; float4 _MainTex_ST; sampler2D _NoiseMap; float4 _NoiseMap_ST; float _Progress; float _EdgeWidth; fixed4 _EdgeColor; struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float2 uv : TEXCOORD0; float4 vertex : SV_POSITION; }; v2f vert (appdata v) { v2f o; o.vertex = UnityObjectToClipPos(v.vertex); o.uv = TRANSFORM_TEX(v.uv, _MainTex); return o; } fixed4 frag (v2f i) : SV_Target { fixed4 col = tex2D(_MainTex, i.uv); fixed noise = tex2D(_NoiseMap, i.uv).r; clip(noise - _Progress); fixed edge = noise - _Progress; fixed edgeFactor = smoothstep(0, _EdgeWidth, edge); col.rgb = lerp(_EdgeColor.rgb, col.rgb, edgeFactor); return col; } ENDCG } } }这段代码是整篇内容的核心骨架。唯一要解释一下的是smoothstep(0, _EdgeWidth, edge),它返回0到1之间的平滑值。edge小于0的情况已经在clip处被丢弃了,所以这里edge的取值范围是(0, 噪声最大值 - 进度)。edge越接近0,说明当前像素越靠近裁切线,edgeFactor越小,混合出来的颜色越偏向边缘高亮色。
2.5 URP下的Shader写法差异
如果你的项目是URP管线,推荐把上面的代码升级为HLSL风格,示例:
Shader "Custom/DissolveURP" { Properties { _BaseMap ("Base Map", 2D) = "white" {} _NoiseMap ("Noise Map", 2D) = "white" {} _Progress ("Progress", Range(0,1)) = 0 _EdgeWidth ("Edge Width", Range(0,0.5)) = 0.05 _EdgeColor ("Edge Color", Color) = (1,1,1,1) } SubShader { Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" "Queue"="Geometry" } Pass { Tags { "LightMode"="UniversalForward" } Cull Off HLSLPROGRAM #pragma vertex vert #pragma fragment frag #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl" TEXTURE2D(_BaseMap); SAMPLER(sampler_BaseMap); TEXTURE2D(_NoiseMap); SAMPLER(sampler_NoiseMap); float4 _BaseMap_ST; half _Progress; half _EdgeWidth; half4 _EdgeColor; struct Attributes { float4 positionOS : POSITION; float2 uv : TEXCOORD0; }; struct Varyings { float2 uv : TEXCOORD0; float4 positionCS : SV_POSITION; }; Varyings vert (Attributes IN) { Varyings OUT; OUT.positionCS = TransformObjectToHClip(IN.positionOS.xyz); OUT.uv = TRANSFORM_TEX(IN.uv, _BaseMap); return OUT; } half4 frag (Varyings IN) : SV_Target { half4 col = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, IN.uv); half noise = SAMPLE_TEXTURE2D(_NoiseMap, sampler_NoiseMap, IN.uv).r; clip(noise - _Progress); half edge = noise - _Progress; half edgeFactor = smoothstep(0, _EdgeWidth, edge); col.rgb = lerp(_EdgeColor.rgb, col.rgb, edgeFactor); return col; } ENDHLSL } } }在URP下需要注意:TransformObjectToHClip替代UnityObjectToClipPos,纹理采样从tex2D变为SAMPLE_TEXTURE2D,这是URP与Built-in写法的主要区别。此外Properties里的主纹理名如果是_BaseMap,在Lit Shader里能自动匹配GPU Instancing,不过对消融这种偏特效的场景问题不大。
3. 实操过程与核心环节实现
3.1 创建工程与Shader文件
打开Unity,版本随意,推荐Unity 2021 LTS以上,因为后续URP支持更稳定。新建工程选3D模板,或URP模板都行。在Project窗口右键->Create->Shader->Unlit Shader,命名为Dissolve。双击打开,把之前Built-in的代码完整覆盖进去。
然后准备一个测试场景:创建一个立方体Cube、一个球体Sphere或一个人物模型。拖拽一个材质球,把Shader指定为Custom/Dissolve。主贴图使用模型自带贴图,噪声图用一张灰度噪点图,初始Progress保持0,现场效果就是正常模型。
3.2 动态控制脚本
接下来用C#脚本控制消融的进度。这个脚本很基础,唯一要注意的是如何从代码中拿到Material实例。直接改renderer.material会创建材质的实例,避免影响其他同材质物体。
using UnityEngine; public class DissolveController : MonoBehaviour { public float duration = 2f; public bool playOnStart = false; private MaterialPropertyBlock materialPropertyBlock; private Renderer targetRenderer; private float progress = 0f; private bool isDissolving = false; private static readonly int ProgressProp = Shader.PropertyToID("_Progress"); void Start() { targetRenderer = GetComponent<Renderer>(); materialPropertyBlock = new MaterialPropertyBlock(); if (playOnStart) { StartDissolve(); } } public void StartDissolve() { progress = 0f; isDissolving = true; } void Update() { if (!isDissolving) return; progress += Time.deltaTime / duration; progress = Mathf.Clamp01(progress); targetRenderer.GetPropertyBlock(materialPropertyBlock); materialPropertyBlock.SetFloat(ProgressProp, progress); targetRenderer.SetPropertyBlock(materialPropertyBlock); if (progress >= 1f) { isDissolving = false; targetRenderer.enabled = false; } } }用MaterialPropertyBlock的好处是:不需要单独实例化材质球,不产生多余的材质实例,性能友好,尤其适用于多个敌人同时消融的场景。如果你要批量控制一群人同时消失,推荐用这个方案。
3.3 结合Animator或Animation的事件折线控制
实际游戏里,消融往往不是匀速进行的。比如角色死亡时,希望他先快速融化,尾段缓慢消散。这时候不要硬在Update里用线性差值,可以用AnimationCurve把进度曲线暴露到Inspector里调整:
public AnimationCurve curve = AnimationCurve.EaseInOut(0,0,1,1); void Update() { progress = curve.Evaluate(Time.timeSinceLevelLoad / duration); }AnimationCurve的好处是很直观,可以在Inspector里拉出想要的溶解节奏,不需要反复改代码。
3.4 多材质子物体同步消融
一个角色往往有多个Renderer(身体、武器、头发),如果希望全身同步消失,可以写一个简单管理脚本,遍历所有子物体上的Renderer,统一设置同一份进度值。要注意的是,不同Renderer的材质球必须使用同一个Shader实例,否则会出现有的部位消失了、有的部位还挂着的情况。
也可以用MaterialPropertyBlock配合相同的Shader属性名,逐个Renderer调用SetFloat,实现全模型同步消融。
3.5 用消融实现传送门、开关门等扩展玩法
消融不仅能做死亡特效,还能用来做传送门:玩家靠近一个石碑,石碑从顶部到底部渐渐"融化出现"。这时候只需要改变消融的方向,阈值从1降到0,就会反过来"凝结成形"。
实现上完全不用改Shader,只需要把脚本里的progress从1减到0,或者直接把_Progress设为1 - progress。
这里还有个小技巧:如果希望溶解从底部向顶部或者从某个轴开始,可以给噪声图采样增加一个轴偏移,利用世界坐标:
float3 worldPos = mul(unity_ObjectToWorld, v.vertex).xyz; float noise = tex2D(_NoiseMap, worldPos.xz * 0.1 + worldPos.y * 0.1).r;这个做法特别适合做地形、山体、巨型建筑的整体消失,比UV采样更抗拉伸。
4. 常见问题与排查技巧实录
4.1 模型边缘出现"白边"或者"硬边"
这是最常踩的坑。很多新手把消融阈值设得大了,导致裁切线正好落在模型表面光照剧烈的三角面上,边缘处就会有一圈细碎的亮边或黑边。
解决办法有几个:
- 把
_EdgeWidth调大一点,让边缘过渡柔和一些。 - 边缘色用模型相近的颜色微调,不要用纯白或纯黑。
- 如果物体有AlphaTest需求,把
Queue调整为Transparent或AlphaTest,避免裁剪排序问题。
4.2 消融不均匀,或者某些区域一直无法消融
这个问题基本是噪声图导致的。如果噪声图某些区域灰度值一直为1,那么无论进度怎么调,那个区域都不会被裁掉,会一直残留在场景里,像一块顽固的口香糖。
排查方式:把_NoiseMap的_Progress临时设成0.5,直接用材质球预览,看看哪些区域还亮着。解决方案是换一张灰度值分布更均匀的噪声图,或者用多张噪声纹理叠加采样:
half noise1 = tex2D(_NoiseMap, i.uv).r; half noise2 = tex2D(_NoiseMap2, i.uv * 3.7).r; half noise = saturate(noise1 * 0.7 + noise2 * 0.3);两张不同频率的噪声图叠起来,消融边缘的细节会丰富很多。
4.3 阴影不跟着消融消失
这个属于常见但不太容易发现的细节。默认情况下,Shader只处理了ForwardBase / ForwardAdd等渲染事件,并没有处理ShadowCaster Pass。因此就算模型表面已经消融得很厉害,它的阴影还是完整的一坨。
解决办法是在SubShader里加一个ShadowCaster Pass:
Pass { Name "ShadowCaster" Tags { "LightMode"="ShadowCaster" } CGPROGRAM #pragma vertex vert_shadow #pragma fragment frag_shadow #pragma multi_compile_shadowcaster #include "UnityCG.cginc" sampler2D _NoiseMap; float4 _NoiseMap_ST; float _Progress; struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { V2F_SHADOW_CASTER; float2 uv : TEXCOORD1; }; v2f vert_shadow(appdata v) { v2f o; o.uv = TRANSFORM_TEX(v.uv, _NoiseMap); TRANSFER_SHADOW_CASTER_NORMALOFFSET(o) return o; } float4 frag_shadow(v2f i) : SV_Target { half noise = tex2D(_NoiseMap, i.uv).r; clip(noise - _Progress); SHADOW_CASTER_FRAGMENT(i) } ENDCG }加了ShadowCaster之后,阴影就会跟着消融区域切碎,细节观感会好很多。
4.4 低端手机上消融边缘出现严重锯齿
消融本质是逐片元裁剪,在低分辨率设备上,被裁掉的边缘很容易产生明显锯齿感。可以开启抗锯齿,或者在后处理上开MSAA。但如果预算紧张,推荐把边缘宽度调大,让过渡带更宽,反而掩盖了锯齿。
同时注意移动端不要使用过多if分支,上面代码中用了clip,GPU对clip的调度友好程度高于动态分支,这是好的编码习惯。
4.5 消融后物体依然可以被点击/碰撞
如果游戏逻辑是"物体消融后应彻底消失",要注意Renderer.enabled=false只能隐藏渲染,Collider依然是激活状态。需要在脚本销毁或禁用Collider,例如:
Collider col = GetComponent<Collider>(); if (col != null) col.enabled = false;在某些场景(比如掉落物消失后不能再捡取),这个细节非常关键。
4.6 如何让多个部件消融时间错开
可以给每个Renderer的材质球设置不同的_Progress初始值或等待时间,实现错峰消融。例如身体先消失,1秒后头部再消失,这种效果用于怪物死亡、Boss解体非常有冲击力。
实现上写一个简单的协调器:
public List<Renderer> parts; public float delayPerPart = 0.3f; public void StartDissolveParts() { for (int i = 0; i < parts.Count; i++) { StartCoroutine(DissolvePart(parts[i], i * delayPerPart)); } }4.7 遇到Shader报错:Fixed function shader is not supported
如果你的Shader写了Fallback Off,而目标平台是老旧手机,有可能出现不兼容问题。建议SubShader末尾写上:
Fallback "Diffuse"这样即便Shader某个Pass不被支持,也能退回Diffuse渲染,至少不会黑屏。同时建议关掉项目的Auto Graphics API里的OpenGL ES2.0选项,现在绝大多数设备都已经支持OpenGL ES3.0+,没必要再兼容ES2。
5. 进阶扩展:消融与其他特效的组合思路
5.1 消融+粒子系统
消融过程中,可以配合发射粒子,模拟火星、烟雾、碎片飞溅。常见做法是在脚本里检测进度值,当进度处于某区间时,每帧在模型表面随机位置发射粒子,这样视觉上"物体一边被烧融,一边掉落碎屑"。
粒子位置可以用Random.onUnitSphere * bound.extents配合Renderer.bounds中心点来估算。如果想更准确,可以在模型上挂几个子物体作为发射点,这种方案简单且可控。
5.2 消融+音效
消融进度开始/结束时触发音效,这个属于锦上添花。脚本里用事件回调或进度阈值判断即可。比如progress > 0.5f时播放一次"熔化溃散"的音效,避免音效从头响到尾造成混乱。
5.3 消融+屏幕后处理
如果你追求更夸张的效果,可以在后处理Shader里针对消融区域做发光模糊。先用RenderTexture标记消融物体的边缘Mask,再做高斯模糊叠加到屏幕上,这样产生大面积的白光晕散。这种特效在一些3A游戏里很常见,不过复杂度会高不少,适合美术向的进阶扩展。
5.4 消融+Shader Graph
如果你不想手写Shader代码,Unity Shader Graph也支持消融。创建一个Unlit Graph,采样噪声贴图,用Step节点和Clip节点即可。最核心的两个节点是Noise和Step,之后把消融进度暴露为Property,边缘发光用Lerp加Smoothstep就能搞定。不过Shader Graph生成的节点网络有时比较乱,还是建议新手先弄懂手写代码的逻辑,再切回Shader Graph会觉得豁然开朗。
6. 性能与兼容性心得
6.1 性能开销从哪来
消融Shader本身并不昂贵,核心开销主要集中在噪声图采样和裁剪指令。同一时间屏幕上出现的消融物体数量是影响性能的关键,如果有几十个角色同时消融,每个角色的面数又很高,Draw Call和Overdraw都会上升。
我的经验做法是:给消融物体单独一个Layer或Tag,消融状态进行中不要参与阴影投射(除了ShadowCaster Pass保持裁剪同步),并且在消融结束后立即禁用Renderer,不要留着透明物体刷屏。
6.2 移动端适配建议
如果目标平台是手机,需要注意:
- 噪声图纹理尽量控制在1024x1024以内,压缩格式用ASTC 6x6或ETC2。
- 避免在Fragment Shader中使用
pow、sin等复杂数学函数,消融逻辑里完全用不到,别多写。 - 控制模型面数,Uber级别的极端消融场景建议用LOD。
- 真机测试时开Frame Debugger,确认消融物体的Pass数量是独立的,而不是多个Pass叠加。
6.3 多平台表现一致性
不同渲染管线下同一个Shader效果可能有差异,尤其是OpenGL和Metal下smoothstep的边缘系数略有不同。如果追求跨平台一致,可以把smoothstep换成(edge / _EdgeWidth)并自己做clamp。性能差异不大,但Metal和Vulkan的浮点精度差异会导致边缘宽度在高分屏上看起来不太一样,遇到这种情况优先用线性插值代替。
7. 一些小技巧与个人体会
做消融效果这几年,我发现一个很多人忽略的点:消融速度一定要跟音效、飘浮粒子、摄像机震动配合好。Shader本身再完美,如果播放节奏不对,玩家依然会觉得"假"。
比如角色死亡时消融持续1秒,粒子在0.3秒才开始大量喷射,闪烁频率又太快,就会觉得割裂。我一般会把进度曲线做成先快后慢的缓动,开头0到0.3秒快速破坏模型轮廓,后面0.3到1秒缓慢褪去,像烧尽的灰烬慢慢飘散,观感会自然很多。
另外一个小技巧,如果要消融的物体有半透明贴图(比如布料、头发),最好把Queue改成Transparent,并关闭ZWrite,避免出现遮挡穿插问题。如果只是特效演示,直接用Opaque也够用。
最后说下,如果你需要做多个物体同时差异化消融,建议写一个DissolveGroup管理器,把每个Renderer的材质球统一初始化,只修改MaterialPropertyBlock,这样既省内存又可以批量控制,后续扩展也比较顺手。