做渲染的同行应该都有共鸣一个角色从实体到完全消失如果只是简单地把 GameObject 隐藏或者直接销毁观众是没什么感觉的。消融效果Dissolve就是解决这种消失演出问题的经典方案——模型边缘像被高温灼烧一样泛起亮光紧接着从一侧或者随机位置一点点化掉最终彻底消失。在 Unity 里实现消融核心思路正是标题里说的动态着色用一个从 0 到 1 的进度值实时驱动表面哪些像素被裁剪、哪些像素叠加边缘高光。这篇文章我不会只丢一个 Shader 文件了事而是把原理、完整代码、C# 控制脚本、常见坑位、Shader Graph 替代路线都展开讲。适合想在项目里直接落地这套效果的 Unity 开发者也适合刚接触 Shader 想找一个完整案例入门的新手。1. 消融效果不只是消失先理解它在做什么1.1 渲染管线里丢弃像素是怎么回事消融效果的底层原理一句话就能说清在片元着色器阶段渲染管线本来会给每个像素都执行一次着色函数而我们可以在这个像素最终写入屏幕之前用clip()指令把它扔掉。被扔掉的位置没有颜色写入自然就镂空了。也就是说不透明物体在渲染时有一部分三角形面积对应的像素被主动放弃剩下的部分仍然是不透明的。这个扔掉像素的动作在图形学里叫 discard在 Unity Shader 里对应clip函数。clip(x)的含义是如果传入的x小于 0就丢弃当前片元。所以只要算出一个值让它能反映当前像素该不该消失再把这个值和消融进度做比较就能控制消失的区域了。这里有个关键认知消融不是让物体变透明而是让物体真的缺一块。正因为被裁掉的部分完全不参与深度测试和颜色写入消融边缘才能呈现出干净的镂空效果而不是像半透明物体那样出现各种排序问题。这决定了后续写代码时的渲染队列选择——它仍然属于不透明物体不需要丢进 Transparent 队列。1.2 为什么非要一张噪声图来参与比较如果只用clip(i.uv.y - _Threshold)你得到的就是一条笔直的横线把模型切成两半然后上半部分整体消失边缘平整得像刀切一样观感很生硬。为了让消融边缘看起来像燃烧、腐蚀、溶解我们需要一个随机值参与比较。噪声图Noise Map存的就是这种随机值。每个像素的明暗不同消融时不同点到达阈值的先后也就不同于是镂空边缘呈现出杂乱、不规则的破碎感。实际项目中常用的噪声图有两类Perlin 噪声适合做烟雾、软边、地形这类连续渐变的效果Voronoi 噪声自带蜂窝状的边缘适合做能量护盾、碎块剥落、科技感消融。如果不追求特定风格一张普通的灰白噪声图加高 Tiling 就能满足大部分需求。噪声图还有一个隐藏用途它是边缘光的触发开关。当某个像素的噪声值刚好卡在消融阈值附近时它距离被裁剪只有一步之遥这个位置天然就是镂空边缘。用阈值区间把这一圈像素筛出来叠加高亮颜色就是大家常说的边缘灼烧光。没有这一步消融就只是干巴巴地挖洞视觉效果会大打折扣。1.3 方案选型手写 Shader 还是 Shader Graph动手前先选路线。现在 Unity 项目要么走 Built-in 管线要么走 URPShader 的实现方式直接影响后续维护成本。方案优点缺点建议场景手写 CG/HLSL Shader性能最可控、不依赖管线版本、任何工具链都能改门槛稍高调试靠肉眼看程序向团队、需要深度定制、项目要兼容多管线Shader Graph可视化、出效果快、美术同学也能调部分节点有额外开销、URP 专属、复杂逻辑表达受限美术向团队、URP 项目、快速原型验证后处理模拟不用改模型材质只能做屏幕空间效果、没法配合模型动画基本不推荐适用范围太窄我个人建议如果你想彻底搞懂消融并且期望它能在长期项目里稳定运行手写 Shader 是绕不开的必修课。这篇文章也以手写实现为主线因为理解了原理之后换到 Shader Graph 只是换了个拼图方式而已。本文后面会单独开一节讲用 Shader Graph 怎么搭方便纯美术向的读者。2. 完整 Shader 实现从零写出可复用的消融效果2.1 Properties 参数和渲染状态设置先创建一个新的 Shader 文件命名DissolveEffect.shader。第一件事是把需要用到的材质参数定义清楚Properties { _MainTex (基础贴图, 2D) white {} _NoiseTex (噪声图, 2D) white {} _DissolveAmount (消融进度, Range(0, 1)) 0 _EdgeWidth (边缘宽度, Range(0.001, 0.5)) 0.05 _EdgeColor (边缘颜色, Color) (1, 0.6, 0.1, 1) _EdgeIntensity (边缘光强度, Range(0, 5)) 2 }每个参数都不是随便加的_MainTex是主体贴图正常显示颜色_NoiseTex负责提供随机值决定了消融边缘的形状_DissolveAmount是核心驱动变量外部脚本改的就是它_EdgeWidth控制边缘发光的带宽太窄容易闪烁太宽会显得像荧光描边_EdgeColor和_EdgeIntensity负责调节边缘光的色调和亮度。SubShader 里有两个关键的渲染状态设置。第一Tags { RenderTypeOpaque QueueGeometry }因为消融的主体仍然是不透明的镂空是硬件裁剪出来的不是真透明。第二Cull Off双面渲染。很多模型在消融到一半时内部结构会露出来如果只渲染正面从某些角度看会出现半边模型透明消失的穿帮感。打开双面渲染后背面也能被着色镂空区域的视觉信息更完整。2.2 核心 Pass裁剪和边缘高光的完整代码下面是主 Pass 的完整代码建议直接复制到项目里跑一遍Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc sampler2D _MainTex; float4 _MainTex_ST; sampler2D _NoiseTex; float4 _NoiseTex_ST; float _DissolveAmount; float _EdgeWidth; float4 _EdgeColor; float _EdgeIntensity; 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 { float noise tex2D(_NoiseTex, i.uv * _NoiseTex_ST.xy _NoiseTex_ST.zw).r; clip(noise - _DissolveAmount); float edge smoothstep(_DissolveAmount, _DissolveAmount _EdgeWidth, noise); edge 1.0 - edge; fixed4 col tex2D(_MainTex, i.uv); col.rgb edge * _EdgeColor.rgb * _EdgeIntensity; return col; } ENDCG }这段代码核心就三件事。第一行采样噪声图得到当前像素的噪声值第二行clip(noise - _DissolveAmount)决定生死——噪声值小于进度值的像素被丢弃形成镂空区域。等一下这里可能有人会绕晕clip(x)的参数大于等于 0 才保留而noise - _DissolveAmount在噪声大、进度小的时候是正数所以正常显示当进度增大越来越多的噪声值小于进度像素被逐渐丢弃。这就实现了进度从 0 到 1模型从完整到消失。边缘光的部分用到了smoothstep。它的作用是把_DissolveAmount到_DissolveAmount _EdgeWidth这段范围映射到 0 到 1噪声值刚好等于阈值时输出 0超过阈值加边缘宽度时输出 1。我们做edge 1.0 - edge取反于是镂空边缘处噪声刚大于阈值的像素edge 接近 1亮度最高越往模型内部edge 快速衰减到 0。最后用加法col.rgb edge * _EdgeColor.rgb * _EdgeIntensity叠加边缘色保留原贴图信息的同时多了一圈灼烧高光。这里有一个新手容易踩的坑边缘光的带宽希望多宽_EdgeWidth就要和clip的阈值区间严格对应。常有人在 Shader Graph 里把阈值和边缘范围用了两个完全独立的参数调了半天边缘光要么不显示要么一条亮线飘在模型中间。统一用_DissolveAmount作为参照系就不会出现这种错位问题。2.3 方向消融让模型从底部向顶部烧上去很多消融需求不是全模型随机消失而是有一个明确的方向感。比如角色死亡时从头开始化为光点或者特效从脚下向上蔓延。方向消融的实现思路是在烧穿线上叠加噪声扰动。先给 Shader 增加几个属性_BurnStart (烧穿起始高度, Float) 0 _BurnHeight (烧穿总高度, Float) 2 _NoiseStrength (边缘扰动强度, Range(0, 1)) 0.1然后在 v2f 里加一个世界坐标变量o.worldPos mul(unity_ObjectToWorld, v.vertex).xyz片元着色器里这样写float noise tex2D(_NoiseTex, i.uv).r; float burnLine _BurnStart _DissolveAmount * _BurnHeight; float threshold burnLine (noise - 0.5) * _NoiseStrength; clip(i.worldPos.y - threshold);这里的思路和全局消融不同不是拿噪声值和进度直接比较而是先算出一条当前烧到的世界高度线进度为 0 时在_BurnStart进度为 1 时抬升到_BurnStart _BurnHeight。比烧穿线低的像素全部丢弃所以底部先消失顶部后消失。为了让消融边缘不是一条笔直的切面用(noise - 0.5) * _NoiseStrength对烧穿线做上下扰动镂空边缘就有了锯齿和不规则感。方向消融的边缘光也可以跟着烧穿线走float edge 1 - saturate(abs(i.worldPos.y - burnLine) / _EdgeWidth); col.rgb edge * _EdgeColor.rgb * _EdgeIntensity;以世界高度差作为距离参考贴紧烧穿线的一圈像素被点亮。注意_BurnHeight大致要覆盖住模型的实际高度否则进度到 1 时模型顶部还没烧完或者还没到 1 就已经全烧没了。如果是移动端游戏还要考虑模型是否做了动态合批、世界坐标的计算会不会破坏合批这一点在后面的优化章节细说。2.4 ShadowCaster Pass别让阴影和模型脱节这是很多第一次写消融效果的开发者最容易漏掉的地方模型已经消融到大半地上的影子却还是一个完整的模型轮廓。原因是自定义 Shader 如果没有处理阴影 PassUnity 会走默认的 ShadowCaster而默认逻辑里没有裁掉消融像素。解决方式很简单手动写一个 ShadowCaster Pass保证投影时也执行同样的clipPass { Name ShadowCaster Tags { LightMode ShadowCaster } CGPROGRAM #pragma vertex vert #pragma fragment frag #pragma multi_compile_shadowcaster #include UnityCG.cginc sampler2D _NoiseTex; float4 _NoiseTex_ST; float _DissolveAmount; struct v2f { V2F_SHADOW_CASTER; float2 uv : TEXCOORD1; }; v2f vert (appdata_full v) { v2f o; o.uv TRANSFORM_TEX(v.texcoord, _NoiseTex); TRANSFER_SHADOW_CASTER_NORMALOFFSET(o) return o; } fixed4 frag (v2f i) : SV_Target { float noise tex2D(_NoiseTex, i.uv).r; clip(noise - _DissolveAmount); SHADOW_CASTER_FRAGMENT(i) } ENDCG }这段代码把消融逻辑复制到了阴影渲染阶段阴影会跟着镂空区域同步变化。如果你用的是方向消融版本ShadowCaster 里也要同步加上烧穿线的计算。这个 Pass 不算难写但忘写之后排查起来很费时间因为游戏运行时主视角画面一切正常镜头拉远了或者切换方向才发现阴影露馅。3. C# 脚本控制让消融效果真正动起来3.1 基础进度控制脚本Shader 写好后如果只是把_DissolveAmount停在 0模型永远是完整的。需要外部脚本在合适的时机改变这个参数让效果动起来。一个最基础的 C# 控制脚本长这样using System.Collections; using UnityEngine; public class DissolveController : MonoBehaviour { public Renderer targetRenderer; public float duration 1.5f; public AnimationCurve dissolveCurve AnimationCurve.EaseInOut(0f, 0f, 1f, 1f); private Material mat; private Coroutine dissolveCoroutine; private void Awake() { if (targetRenderer null) targetRenderer GetComponentRenderer(); // 注意这里是 material 而不是 sharedMaterial会产生材质实例 mat targetRenderer.material; } [ContextMenu(播放消融)] public void PlayDissolve() { if (dissolveCoroutine ! null) StopCoroutine(dissolveCoroutine); dissolveCoroutine StartCoroutine(DissolveRoutine(0f, 1f, duration)); } [ContextMenu(播放恢复)] public void PlayRecover() { if (dissolveCoroutine ! null) StopCoroutine(dissolveCoroutine); dissolveCoroutine StartCoroutine(DissolveRoutine(1f, 0f, duration)); } private IEnumerator DissolveRoutine(float from, float to, float time) { float timer 0f; while (timer time) { timer Time.deltaTime; float t Mathf.Clamp01(timer / time); float value Mathf.Lerp(from, to, dissolveCurve.Evaluate(t)); mat.SetFloat(_DissolveAmount, value); yield return null; } mat.SetFloat(_DissolveAmount, to); // 全消后自动隐藏方便接入死亡流程 if (to 1f) targetRenderer.gameObject.SetActive(false); } }这段脚本做了三件重要的事第一在Awake里用targetRenderer.material创建了材质实例这避免了场景里多个物体共用同一个材质时一个物体消融其他物体跟着消融的尴尬局面。第二用协程做进度插值而不是在Update里直接写死逻辑好处是方便外部随时打断、反转。第三引入了AnimationCurve让消融速度可以调节——游戏里常见的做法是开头快、中间慢、最后快曲线一调就完事。如果把脚本挂到角色上在 Inspector 里右键组件可以看到PlayDissolve和PlayRecover两个菜单项可以直接在编辑器里点一下预览效果调试非常方便。这段代码里用到的mat.SetFloat(_DissolveAmount, value)对应 Shader 里的参数名如果换了名字记得同步修改。3.2 与动画系统、音效、特效的联动很多新手把消融做成一个播放一遍就完事的效果但实际上游戏里消融很少单独出现它通常是整个演出流程的一环。角色死亡时可能是先播放受击动画停顿一瞬然后音效响起、粒子爆开最后模型才开始消融。这些环节的衔接需要有一个统一的调度者。最简单的做法是利用 Unity 的Animation或Animator直接对材质参数做动画曲线。把_DissolveAmount作为一个被动画驱动的属性在 Animation 窗口里手动打两个关键帧比如第 0 帧值是 0第 30 帧值是 1再配合AnimationEvent在指定帧触发音效和粒子。这样做的好处是美术同事可以完全可视化地调节节奏不依赖程序写死的时间。如果是程序驱动我习惯给DissolveController加一个事件回调public System.Action OnDissolveFinished;在协程结束时触发。外部监听者收到回调后再去执行对象池回收、掉落物生成、任务计数等逻辑。这样消融效果和业务逻辑解耦以后替换成别的消失效果也方便。还有一个实际场景需要注意消融不一定非要从 0 到 1。比如数字孪生项目里的设备拆解演示进度条可能只走到 0.4 就停下保持半拆开的状态方便观众看到内部结构。这种情况下不要把动画播放完就隐藏物体而是把_DissolveAmount停在指定值上。脚本里PlayRecover这个反向方法就是为这类场景准备的想看内部结构时消融到一半看完再恢复回去比直接换个模型方便得多。3.3 用粒子系统辅助演出效果消融边缘在模型上是一圈亮光但如果加的料不够还是显得有点干。我做过几个项目之后发现给消融挂一个简单的粒子系统表现力会提升一个档次边缘光负责视觉引导粒子负责填充正在飘散的细节。粒子的发射位置可以绑定到角色骨骼或者模型表面某个子物体发射方向朝外。关键点在于粒子的生命周期应该和消融进度对齐——如果角色用了 2 秒烧完粒子就要在 2 秒内陆续喷出而不是一次性爆出。做法很简单在Update里根据_DissolveAmount的当前值和上一帧的差值决定这一帧要不要Emit几个粒子。消融进度增长最快的中期喷发量最大刚开始和快结束时粒子数量递减。这个节奏平滑了之后整体观感会很自然。4. 踩坑记录与优化建议4.1 常见问题速查表做消融效果这几类问题出现频率最高我整理成一张速查表你在项目里遇到直接对号入座现象常见原因解决办法模型消融后阴影还完整立在地上ShadowCaster Pass 没写投影没走裁剪逻辑手动添加 ShadowCaster Pass同步 clip边缘光显示为一条特别宽的荧光带_EdgeWidth太大或者smoothstep区间和 clip 阈值不一致把区间统一到_DissolveAmount附近宽度调到 0.03–0.08消融时出现明显的断层分块边缘不自然噪声图 Tiling 太低噪声值大片连续提高噪声图 Tiling或叠加两层噪声多个物体使用同一个材质一个消融全部跟着消融改了sharedMaterial使用renderer.material实例化或按需动态创建材质边缘光在手机上闪烁得厉害噪声图开 MipMap、精度不足或片元着色器用了低精度变量关掉噪声图 MipMap使用 R8 灰度图关键值用 float 计算切换到方向消融后模型进度没到 1 就全消失了_BurnHeight比模型实际高度小拿模型包围盒高度给_BurnHeight赋值这里重点展开说一下噪声图精度的问题。如果噪声图本身是 8 位 RGBA 图某个通道的数值在消融阈值附近时可能会因为精度不足产生肉眼可见的色阶断裂在动态播放时表现成边缘抖动。解决办法有两个一是把噪声图导入设置里的Compression改成NoneGenerate Mip Maps关掉二是采样后对噪声值再做一次smoothstep或fwidth抗锯齿处理让边缘过渡更干净。4.2 性能优化要点一个项目里可能同时有几十个怪物在播放消融如果不控制好性能再好看的效果也会拖垮帧率。第一个优化点是材质实例数量。使用renderer.material会为每个物体创建独立材质实例如果几十个物体同时消融Draw Call 和内存都会上涨。一个折中方案是只有需要播放消融的角色才实例化材质消融结束后立即把材质换回共享材质并销毁临时实例。如果你用的不是 URP 的 SRP Batcher这个问题尤其明显。第二个优化点是 Shader 变体。如果只为了边缘光给所有消融物体都开一套完整功能无形中增加了寄存器压力和指令数。可以利用材质关键字做分级低端机上的消融只保留clip和边缘光不启用方向消融的复杂计算或者用[Toggle]属性把方向消融编译成独立变体只有需要的时候才启用。Shader 变体增多有打包体积和编译时间成本控制好数量。第三个优化点是噪声图的分辨率。噪声图不需要 1024×1024的高分辨率256×256 甚至 128×128 完全够用配合高 Tiling 一样能实现细腻的边缘。低分辨率噪声图还自带一点模糊效果反而能减少边缘锯齿感。4.3 URP 项目里的适配和 Shader Graph 替代方案如果你的项目已经迁移到 URP手写 CGPROGRAM 的 Shader 不再适用需要改成 HLSL 并引入 URP 的库。不过不用慌消融的核心逻辑跟管线无关只是头文件和宏的名字变了。更省事的方式是在 URP 里用 Shader Graph 做消融。Shader Graph 搭消融效果的核心思路和手写完全一样第一步用Sample Texture 2D采样噪声图第二步用Step节点或者Clip节点把噪声值和_DissolveAmount比较输出为透明蒙版第三步用Step或者Smoothstep节点筛出边缘区域和_EdgeColor相乘加到Emission或Base Color上第四步把_DissolveAmount暴露为Float属性连接到主节点外部脚本用同样的SetFloat驱动。有个细节要提醒Shader Graph 里如果要在 URP 管线的 Lit 主节点上实现消融要把Surface Type设置成Opaque不要设成Transparent否则又回到透明排序的坑里。消融镂空靠 Alpha Clip Threshold 是不行的需要自己接Clip节点。URP 的Alpha Clip Threshold本质上是内部帮你调用clip但如果你要的镂空形状是噪声驱动的直接把Noise节点输出接到Alpha Clip Threshold上更直接。4.4 再说一点关于动态着色的扩展思路消融只是动态着色的一种形态理解了这套外部参数驱动 Shader 内部状态的架构可以延伸出很多类似效果。比如扫描线效果用世界坐标 X 轴做进度值让高亮区域在模型表面来回移动再比如局部损坏效果用顶点色或者遮罩图控制哪些部位先被消融还可以做神器的充能激活效果——从中心向外扩散的发光范围本质上也是用一个进度值控制高亮区域的大小。我自己的经验是写这些动态效果时候尽可能把进度值和形状控制这两个维度分开。进度值是外部传入的时间相关变量只管 0 到 1 变化形状控制是噪声图、UV 坐标、世界坐标这些空间信息决定镂空或者高亮发生在哪个位置。只要这两个维度解耦后续想加方向、加遮罩、加区域限制都只是加一张图或者一个坐标计算的事不用推倒重来。收尾前最后分享两个小细节第一个是关于_DissolveAmount从 0 开始但模型边缘依然有一丝亮线的问题。有些美术会觉得还没开始消融时边缘光不应该出现。解决办法是把边缘光的出现时机加一个偏移当_DissolveAmount小于某个值比如 0.05时强制把边缘亮度乘 0。这个细节看起来不起眼但在角色待机循环播放消融预览的场合能避免模型莫名其妙发光的尴尬。第二个是对象池场景下的材质参数重置。如果角色被回收后再复用一定要把_DissolveAmount重置为 0。这个 bug 我见过太多次池化物体消融完成后被回收下次放出来的时候模型刚一开始就已经消融了一半。建议在OnEnable时统一调用mat.SetFloat(_DissolveAmount, 0)而不是依赖上一次协程刚好走完。实际做下来你会发现消融效果的技术难点从来不在 Shader 本身而在于细节——阴影是否同步、边缘光是否干净、进度控制是否可打断、性能是否扛得住同屏多个物体。把这些问题逐个处理干净这个效果才能真正从 Demo 走向上线。希望这篇文章能帮你少走几步弯路。