做游戏特效和渲染表现这些年消融效果一直是我最常用也最爱用的一类需求怪物死亡时身体像被魔法一点点烧掉、Boss战胜利后残骸化为灰烬、场景切换时墙壁像被高温熔穿——这些在Unity里大多靠同一种技术实现就是“动态着色 消融效果”。所谓动态着色不是贴一张会流动的贴图那么简单而是让Shader在运行时逐帧修改像素的去留和颜色把整块物体从完整状态过渡到消失状态。这套技术做起来不难但要让效果不廉价、性能不出问题、还能被策划自由控制进度里面有不少细节值得聊。这篇文章我按自己的实际项目经验来写核心思路、Shader写法、边缘发光和烧焦处理、C#驱动逻辑最后是常见问题和性能避坑。适用于URP管线我用的是Unity 2021.3 LTS URP 12这套方案稍微改改也能放到内置渲染管线和Shader Graph里。1. 消融效果怎么做出来的方案选型与渲染思路1.1 消融不是简单的透明而是“像素级消失”很多第一次做消融的同学第一反应是用材质透明度做淡出。但做过一遍就会明白单纯降低Alpha值角色看起来不是“消失了”而是变成一块越来越透明的玻璃内部、边缘、穿模问题全暴露出来。尤其角色身上有多层材质、披风、头发的时候半透明排序会让人头大前后关系稍微一乱画面就开始闪烁根本不像是被烧毁或者汽化。真正的消融需要做到同一个三角形网格的不同部分消失时间不一样。这个效果靠Alpha混合是做不到的因为我不能在同一个不透明物体的内部做出“局部透明”只要三角形还在要么整片覆盖要么什么都不画。这时候就得依赖GPU的片元级裁剪机制。在Fragment Shader阶段我可以对每个像素单独做判断如果满足某个条件就保留这个像素不满足就直接丢弃。丢弃的像素不会写入颜色缓冲和深度缓冲对后续渲染也不可见。这样同一个三角形上有的像素活着有的像素消失视觉上就会出现类似“被啃掉一口”的效果。但问题是怎么决定哪些像素先消失、哪些像素后消失拿一个纯色物体来说如果所有像素的丢弃条件都一样整个物体要么全在、要么全消失没有中间过程。要让消融变得连续自然就需要给每个像素一张“差异化指令地图”这就是噪声贴图的核心价值。噪声贴图本质上是一张灰度图不同UV位置的像素会采样到不同的灰度值。写Shader时我用一个叫“消融进度”的浮点参数作为阈值把这个阈值和当前像素的噪声值做比较当进度刚起步时只有少数区域被裁掉进度慢慢变大越来越多低噪声值区域消失最后只剩高亮区域还在直到全部消失。因为噪声灰度总是不规则的物体消失的形状也就会不规则的剥离就像被逐渐腐蚀掉一样。1.2 动态着色在这里扮演什么角色如果只是用一张噪声图做Clip其实还算不上“动态着色”更像是一个静态裁切动画。加了“动态着色”这个关键词以后我理解的方案是在消融过程中物体表面颜色也要跟着变化这种变化可以由进度值、噪声采样值、甚至时间一起驱动。最早做项目时我的需求是“敌人被命中后开始溶解溶解边缘有高温火光颜色从亮白过渡到暗红最后碎成灰烬”。这个需求里颜色变化不是固定不变的贴图而是在Shader里实时计算的。可以说动态着色是消融效果的上层包装而裁切只是底层机制。也正是因为需要“动态”持续变化我强烈不建议用美术做一串序列帧来代替。序列帧在小范围、固定视角的特效里能用但一旦模型复杂、镜头拉近或旋转序列帧会出现穿帮资源量也爆炸。动态着色方案用一个Shader加上一张噪声图通过脚本实时改Float参数就能驱动不同物体还能共用材质资源上特别省。另外动态着色还能很好地和其他效果联动。比如我在边沿高亮的部分叠加一个Emission值当进度变化时发光区域会跟着阈值移动。换句话说物体表面有一层“看不见的火焰”火焰的位置由噪声阈值实时计算出来和我要求的效果完全吻合。1.3 Shader Graph 还是手写 Shader具体到Unity实现不少同学第一反应是打开Shader Graph拖节点。Shader Graph好处是可视化美术同事可以直接调参节点图上拖一拖在URP里创建Lit材质很快。如果你团队分工里这个效果主要由TA负责、美术要频繁试渐变和颜色用Shader Graph更高效。但我个人在这个需求里选择的是手写HLSL. 原因不是Shader Graph不能用而是消融效果牵扯到的细节挺多怎么处理边缘采样方向、怎么避免在阴影通道里产生错误遮挡、怎么加入MaterialPropertyBlock控制多个物体的随机性这些在Shader Graph里拖起来反而不直观。虽然Shader Graph也有Custom Function节点可以插入代码但最后还是绕不开写代码。我的建议是无论你用哪种方式都要理解背后的算法。Shader Graph和手写的核心逻辑是一样的采样噪声、设阈值、裁切像素、在阈值附近算边缘强度、输出颜色。这篇文章下面我用手写为例讲原理如果你项目里用Shader Graph完全可以用同样的节点关系复刻出来。2. URP下从0写一个基础消融着色器2.1 工程准备与Shader基本结构先假设你已经建好了一个URP工程用的是至少Unity 2021.3。如果项目还在用旧版内置管线代码里的引用和宏会略有差异不过逻辑一样主要是把HLSLPROGRAM、Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl这些都替换成对应的内置库。我的做法是创建一个Unlit风格的消融Shader先不看光照把“如何消失”这件事彻底跑通再接基础色、发光色等参数。因为真实项目里如果要求不高消融发生在极短时间角色本身会叠加受击特效没有光阴也很少有人注意到。如果你需要物体被光照影响建议基于URP的Lit改或者用Lit风格的Shader Graph再套一层Alpha Clip。打开Unity Asset面板右键Create - Shader - Unlit Shader创建后先删掉自带模板从空白写起。整个Shader我习惯按区块组织Properties放材质参数SubShader里放Pass主逻辑最后用Fallback做好兜底。先放基础参数Properties { [Header(Base)] _BaseMap (Base Texture, 2D) white {} _BaseColor (Base Color, Color) (1,1,1,1) [Header(Dissolve)] _NoiseTex (Noise Texture, 2D) white {} _Progress (Progress, Range(0, 1)) 0 }_NoiseTex那张图我建议直接让美术出用Substance Designer做一版Perlin噪声/Flow Noise混合或者你在Photoshop里生成一张云纹灰度图也行。注意三点第一最好是灰度图不需要Alpha通道第二尺寸不用太大256x256足够除非镜头贴得很近第三内容不要有硬边分辨率低一点反而更细腻。2.2 加入噪声采样与裁切Shader的主逻辑在Pass里我需要先拿到模型UV然后在Fragment阶段采样噪声比较阈值并裁切。核心代码如下Shader Custom/Dissolve { Properties { _BaseMap (Base Texture, 2D) white {} _BaseColor (Base Color, Color) (1,1,1,1) _NoiseTex (Noise Texture, 2D) white {} _Progress (Progress, Range(0,1)) 0 } SubShader { Tags { QueueGeometry RenderTypeOpaque } Cull Off ZWrite On Pass { HLSLPROGRAM #pragma vertex vert #pragma fragment frag #include Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl struct Attributes { float4 positionOS : POSITION; float2 uv : TEXCOORD0; }; struct Varyings { float4 positionHCS : SV_POSITION; float2 uv : TEXCOORD0; }; TEXTURE2D(_BaseMap); SAMPLER(sampler_BaseMap); TEXTURE2D(_NoiseTex); SAMPLER(sampler_NoiseTex); CBUFFER_START(UnityPerMaterial) float4 _BaseMap_ST; half4 _BaseColor; float4 _NoiseTex_ST; float _Progress; CBUFFER_END Varyings vert (Attributes input) { Varyings output; output.positionHCS TransformObjectToHClip(input.positionOS.xyz); output.uv TRANSFORM_TEX(input.uv, _BaseMap); return output; } half4 frag (Varyings input) : SV_Target { float noise SAMPLE_TEXTURE2D(_NoiseTex, sampler_NoiseTex, input.uv * _NoiseTex_ST.xy _NoiseTex_ST.zw).r; float threshold lerp(-0.02, 1.02, _Progress); clip(noise - threshold); half4 baseColor SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, input.uv) * _BaseColor; return baseColor; } ENDHLSL } } }这里有几个细节我特别想强调。第一为什么阈值要写成lerp(-0.02, 1.02, _Progress)而不是直接用clip(noise - _Progress)因为很多Perlin噪声贴图的暗部并不是严格等于0而是接近0。如果进度刚为0时直接拿噪声和0比较画面里所有接近0的暗部都会被裁剪掉物体一出生就自带破洞。差评极其难看。用-0.02到1.02的映射保证进度在0时所有像素都安全保留进度到1时所有像素都必然消失区间完整覆盖。第二Queue和RenderType我特意写成了Geometry和Opaque。有同学看到“消融”可能会下意识改成Transparent这是典型误区。我们并没有使用混合而是直接丢弃像素。被裁掉的区域是不写入颜色的所以本质上是OnePass的不透明渲染不需要半透明排序。如果写了Transparent队列性能反而变差还会出现奇怪的遮挡问题。2.3 材质参数怎么设播放时要注意什么创建材质后把Noise Texture拖进去可以先把Progress放在0看物体是不是完整显示。正常情况下物体看起来和普通Unlit材质没有任何区别。手动把Progress拉到0.5就能看到有部分区域被切开随着进度接近1残留部分越来越少到1时完全消失。这里有个实际操作经验在Inspector里手动拉Progress做测试是第一步但真正做表现时这个值最好完全交给脚本不要手动在细节面板里改。原因是动态着色很容易被反复改出不可复现的状态美术在编辑器里调了一半切到Play模式材质状态是混乱的运行时计算结果可能出乎意料。我的习惯是给初始材质默认值规定为Progress等于0任何测试都先重置材质再跑避免“为什么一进场景物体就缺了一块”这种乌龙。另外一个容易踩的坑是噪声UV缩放。我上面代码的_NoiseTex_ST包含了Tiling和Offset如果模型UV范围跨得很大噪声图会在同一模型表面重复很多次。重复次数少熔解区域大块大块地掉重复次数多像细小颗粒在飞散更有“粒子化”感觉。建议根据模型大小来微调Noise Tiling而不是直接用默认1我记得做过一个2米高的角色Tiling设成0.8视觉效果最像撕开而小石头模型反而要设到2到4才会有碎裂感。3. 让效果“好看”边缘高亮与烧焦色3.1 边缘高亮的数学原理基础裁切做出来后物体消失的瞬间边缘是硬生生的锯齿说白了就是游戏里常说的“硬边”看久了很廉价。因为我们只是在阈值处一刀切多保留一个像素或少保留一个像素会产生极剧烈的亮度变化最后表现为像素级锯齿和闪烁。为了让边缘过渡平滑我引入了“边缘强度”的计算。思路是在裁切阈值附近设置一个过渡带宽_EdgeWidth当当前像素的噪声值离阈值很近时把这个像素标记为“边缘区域”并输出一个高亮颜色离阈值较远、已经深入物体内部的像素则保持原本纹理颜色。计算方式是这个float noise ...; float threshold lerp(-0.02, 1.02, _Progress); clip(noise - threshold); float edgeFactor saturate((noise - threshold) / _EdgeWidth); edgeFactor 1.0 - edgeFactor; // 离阈值越近越大edgeFactor在噪声值刚好等于阈值时是1但它所在像素已经被Clip丢弃了所以实际能看到的边缘是紧贴阈值内侧的一圈像素它们的edgeFactor都接近1。当像素深入内部、噪声远大于阈值时edgeFactor接近0。用这个值去混合颜色就能制造出柔和的光晕带。更重要的是这条边缘带的位置不是固定的而是跟着阈值实时移动。_Progress一变化整圈高亮也会跟着从表面逐渐往网格深处爬视觉上就像火焰从外往里吞。这就是动态着色带来的核心体验消融过程是活的。3.2 在一个着色器中同时输出高亮和烧焦拿到edgeFactor以后后面的一切都顺理成章了。先加几个参数_EdgeColor (Edge Color, Color) (1, 0.5, 0, 1) _EdgeWidth (Edge Width, Range(0, 0.2)) 0.05 _BurnColor (Burn Color, Color) (0.2, 0.05, 0, 1)Fragment里我这样输出颜色half4 baseColor SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, input.uv) * _BaseColor; float edgeFactor 1.0 - saturate((noise - threshold) / _EdgeWidth); half3 color baseColor.rgb; color lerp(color, color * _BurnColor.rgb, edgeFactor * 0.8); color _EdgeColor.rgb * edgeFactor;意思是边缘附近先压暗烧焦再加亮橙色高亮。边缘越近烧焦越明显同时高亮越亮。越靠内部越保持原样。这样消融的边缘既有高温发光的橙黄色又紧贴着一圈暗红焦痕观感立刻上一个档次。关于颜色参数我项目里通常默认_BurnColor是暗红褐色但有些特殊怪物死亡我希望是紫色魔法瓦解那我会把_EdgeColor直接调成紫色烧焦部分调成深蓝紫本质都是在边缘区域做色调偏移和发光输出。只要保留“同一边缘因子驱动两个不同颜色”的逻辑换一种颜色就是一套新特效。还要说一句这里的发光是加法叠加在返回颜色上并不一定代表真的被实时全局光照点亮。如果模型处于一个超暗场景里这样的边缘颜色会显得突兀。此时可以给_EdgeColor加一个强度系数_EdgeIntensity方便美术调节或者接到Shader Graph的Emission通道。3.3 如何把边缘做得更可控边缘宽度_EdgeWidth这个参数对最终效果影响很大。设太大会让发光的范围覆盖接近一半物体消融的时候像在融化而不是高温溶解。设太小又会出现一点一点闪烁。我实际用的区间一般是在0.02到0.08之间具体根据噪声贴图的灰度分布来调别太迷信一个固定值。如果你希望边缘的颜色不是整圈均匀而是有一种“随着噪声脉冲变化”的感觉可以再引入时间变量。比如在算edgeFactor之前把阈值加上一点_Time.y * _PulseSpeed做扰动这样边缘会有闪烁感某些像素高亮跳跃性更强看起来像火焰跳动。但要注意这个扰动幅度不能太大否则会把clip裁切边界弄乱导致物体在还没完成消融时就有大片提前消失。还有一点很有用在计算噪声时把UV乘上一个旋转矩阵或者加入第二张细节噪声让边缘位置不是完全固定。两张噪声图叠乘后大块噪声控制“何时消失”小块细节噪声控制“边缘细节”消融轮廓会显得更丰富。美术消耗其实不大但看起来会比单张噪声高级不少。4. 从Shader到游戏逻辑脚本控制与多物件管理4.1 写个简单的消融控制脚本Shader准备好了下一步肯定是让策划或者代码在游戏过程中控制消融进度。我用一个很简单的C#组件来演示思路直接把材质上的_Progress从0线性推到1即可using System.Collections; using UnityEngine; public class DissolveEffect : MonoBehaviour { [SerializeField] private Renderer[] targetRenderers; [SerializeField] private float duration 1f; [SerializeField] private AnimationCurve curve AnimationCurve.EaseInOut(0,0,1,1); private MaterialPropertyBlock block; private float progress; public void Play() { if (block null) { block new MaterialPropertyBlock(); } StopAllCoroutines(); StartCoroutine(ProgressRoutine()); } private IEnumerator ProgressRoutine() { float elapsed 0f; while (elapsed duration) { elapsed Time.deltaTime; progress Mathf.Clamp01(elapsed / duration); progress curve.Evaluate(progress); foreach (var r in targetRenderers) { if (r null) continue; r.GetPropertyBlock(block); block.SetFloat(_Progress, progress); r.SetPropertyBlock(block); } yield return null; } } }这个组件里用了MaterialPropertyBlock而不是直接给r.material赋值。原因是多个敌人可能共用同一个材质如果直接改材质所有敌人的消融进度会被同步控制还会产生材质实例泄漏非常不划算。用MaterialPropertyBlock每个Renderer拥有自己独立的Shader属性覆盖又不会复制材质。场景里哪怕有100个敌人同时消融内存也扛得住。我把进度处理成动画曲线AnimationCurve是因为“匀速消融”并不总是最好看的。有些怪物理应先快后慢先被撕开大半边最后一点残渣飘一会儿。曲线自由度大调起来也比直接改代码里的速度方便很多。4.2 多物体同步与随机化播放实际项目里消融很少只作用在一个简单的Cube上。一个Boss全身可能有多个部件外壳、内衬、武器、披风它们分别挂在不同的Renderer上。如果每个Renderer使用不同材质或不同进度要保证视觉上是“同一个整体”在溶解就必须让所有部件的_Progress同步变化。我处理这种多部件同步的方式就是把所有需要同时消失的Renderer拖进上面targetRenderers数组里统一执行同一个协程。但需要注意如果不同部件用了不同的UV分布或不同贴图单靠同步进度还不够。比如头部小、身体大在相同阈值下初始出现空洞的位置会不同看起来头先没、身体后没节奏不一致。解决办法有两种。第一种是让所有部件共享同一套噪声图坐标方式比如使用世界空间坐标作为噪声采样坐标而不是各自模型的本地UV。这样不同部件的同一空间位置噪声值一致消融会在表面形成连续的整体撕裂线。第二种是每个部件单独设置一个“噪声采样偏移”参数让各部件在同一条整体进度下出现细微差别做一个“身体比头晚消失0.2秒”的先后效果往往更真实不会像模具翻模一样一模一样。有时候策划还想要“尸体消失后留一点灰烬粒子”的效果。这个用Shader本身做不出来需要配合ParticleSystem。我的做法是在Progress超过约0.7以后从Renderer的包围盒顶点随机生成粒子。别把逻辑混在Shader里Shader管好视觉裁切C#管好事件触发器这样团队协作更清晰。4.3 用MaterialPropertyBlock避免材质泄漏在这里我要把一个最常见的错误单独拎出来说有些同学在Start函数里写GetComponentRenderer().material.SetFloat(_Progress, ...)。第一次看没问题但Unity文档里说明过访问renderer.material会自动实例化一份新材质。你每次调用都可能产生一份内存拷贝而且这份拷贝永远不会自动销毁。如果玩家在一局游戏里反复杀怪每一次都会泄漏一份材质副本游戏越玩越卡内存占用越来越高最终在手机上卡成PPT。正确姿势就是我上面的脚本里那样用MaterialPropertyBlock。它不会创建材质而是为一个Renderer单独保存Shader属性覆盖。渲染时Unity会把这个覆盖合并到材质属性里但不会修改共享材质本体的_Progress。这样同一个材质可以被100个敌人共用每个人通过自己的Block拥有独立的消融值。还有个小点如果你用了SkinnedMeshRenderer这套逻辑同样适用只要把targetRenderers声明成Renderer[]拖SkinnedMeshRenderer进去就行。MaterialPropertyBlock在URP下对SkinnedMeshRenderer也有效不需要额外处理。5. 常见问题大坑与性能调优5.1 常见问题排查表我把这几年遇到最多的消融效果问题整理成一个速查表方便你对照排查现象原因处理建议进度为0时物体已经有洞噪声图暗部接近0阈值起点没留余量把阈值改成lerp(-0.02, 1.02, _Progress)进度到达1但画面还有零星亮点噪声图存在接近1的高亮区域阈值到1时依然能通过剪裁将上限设到1.05以上或者结束后关闭Renderer物体被裁掉的区域仍然遮挡后面物体没有写入正确的Depth保持Pass里ZWrite OnRenderType不要随意改Transparent边缘发光出现明显锯齿过渡带太窄或噪声图分辨率过低调大_EdgeWidth换512分辨率噪声消融后阴影还是完整方块ShadowCaster Pass没有做同样的裁切补ShadowCaster Pass或在Shader Graph中开启Alpha Clip大批量角色消失时掉帧明显每帧大量像素被ClipGPU无法有效Early-Z做LOD减少高级别网格控制同屏数量场景里多个敌人共用材质一个播放全部跟着播放直接改了renderer.material改用MaterialPropertyBlock不要实例化材质URP打包后Shader变成粉色漏了URP引用或打包时没包含Shader变体确保Shader在Always Included Shaders里或放入Resources表格里最后一条打包问题很烦人。URP工程里如果Shader是动态创建的在打包时可能被裁剪掉。最简单的办法是把Shader放到Project Settings里的Graphics Settings的Always Included Shaders列表。另一个更稳妥的方式是把它加入到某个材质里只要那个材质被场景引用Unity就会把依赖一起打进去。5.2 阴影和渲染队列方面的大坑阴影是最容易被忽略的细节。如果你的消融Shader只是把Fragment裁了但ShadowCaster Pass没有做同样的操作那么物体会出现“主体已经消失一半但投在地面上的阴影还是完整一块”的穿帮。在URP的手写Shader里你需要额外增加一个ShadowCaster Pass在里面同样采样噪声并且做相同的clip判断。稍微讲究一点的做法是把片元裁切逻辑抽离成一个函数主Pass和ShadowCaster Pass共同调用避免两处代码不一致。Shader Graph就简单很多只要在材质设置里开启Alpha Clipping并将Noise和Progress接进AlphaClipThresholdURP会自动为ShadowCaster生成对应的裁切。另外一个坑是渲染队列。我见过有人把消融Shader的Queue从Geometry改成Transparent理由是“带裁切的材质不是透明吗”。结果一运行物体和半透明特效、粒子系统混淆出现各种谁先谁后的渲染错误。要知道用clip裁掉的像素并不参与透明混合它只是不写入结果所以在渲染队列上它仍然属于不透明物体。把RenderType保持Opaque或TransparentCutout把Queue保持Geometry才是正确的。5.3 一些性能与美术上的建议最后说说性能。消融本身的Shader开销其实很小一次噪声图采样、一次比较、一个边缘插值几乎可以忽略不计。真正的性能问题来自两个地方。第一是裁切带来的Overdraw问题。GPU对于不透明物体的渲染通常可以靠Early-Z跳过被遮挡的像素但一旦写了clipGPU在大多数移动端架构上就没法放心地提前剔除片元了因为被裁掉的片元会改变结果。也就是说一个本应被墙遮挡的NPC只要正在消融仍可能引发额外的片元计算。所以不建议对超大物体做持续很长的消融更不建议一堆大型怪物同时在屏幕里消融。如果确实有这个需求用LOD先切低模让被裁切的三角形数尽量少。第二是噪声贴图的采样频率。尽可能使用256或者512的灰度图不要上来就是4K。因为噪声贴图是低频信息高分辨率带来的细节增益几乎看不出来只会拖低帧率。并且建议把噪声贴图的压缩格式设为RGB Compressed或R8不需要RGBA也不需要HDR。根据我的项目经验一套消融效果真正能拉开差距的往往不是Shader代码本身而是细节有没有为噪声图做Tiling匹配、有没有给阴影通道做同步、有没有把脚本和材质管理干净。先把基础Clip版本做出来再一步步加边缘光、烧焦色、脚本控制你就能在一个Unity项目里既拥有不错的视觉冲击力又不用担心后期优化追债。尤其是MaterialPropertyBlock带来的多对象管理方案强烈建议一开始就用上别等写了几十份材质再回头改。