在游戏里,角色死亡后飘散的灰烬、怪物被法术命中后燃烧殆尽、场景中冰块慢慢融化成水——这些效果大部分都是同一类技术:Unity里的消融效果。简单说,消融就是让一个物体从完整状态逐渐消失,而实现的核心手段,就是对贴图采样结果做动态着色处理,再配合透明度裁剪(Clip)把像素一片一片"切掉"。这篇文章我会从Shader原理讲到C#侧控制,再到边缘发光、噪声贴图进阶,最后附带我实际项目中踩过的坑和排查思路。不管你是刚入门Shader的新手,还是已经在写材质的老手,这套流程都可以直接抄作业。
1. 动手之前先弄清楚:消融效果到底在做什么
1.1 消融效果的常见实现路径
消融效果在游戏引擎里有很多种做法,最常用的有三类。
第一类是基于透明度裁剪(Alpha Test / Clip)。Shader里采样一张噪声贴图,把噪声值作为透明度,当透明度低于某个阈值时用clip()函数直接丢弃该像素。这样物体表面就会像被啃掉一样,出现不规则的镂空区域。阈值从0到1渐变时,镂空区域逐渐扩大,看起来就是物体在逐渐消失。这也是绝大多数消融效果的基础方案,性能开销很小,移动端也能跑。
第二类是基于顶点偏移(Vertex Offset)+ 背面剔除。让物体的顶点朝法线方向逐渐向外或向内移动,同时配合透明度的变化,模拟物体像沙子一样坍塌、剥落。这个方案比Clip更"立体",但顶点数不足时容易穿帮,对模型面数有一定要求。
第三类是基于溶解(Dissolve)+ 粒子补偿。本质还是Clip方案,但额外在镂空边缘生成粒子系统,把那些"消失"的部分用粒子的形式表现出来,视觉上更有冲击力。比如怪物死亡时,身体边消失边冒火星,就是消融Shader和粒子特效配合的结果。这个方案在动作游戏里最常见。
这篇文章主要讲的,就是第一类方案——基于噪声贴图、透明度裁剪、可动态调整阈值的Unity消融效果。原因很简单:它适用范围最广、性能最可控、代码量最小,而且在这个基础上加边缘发光、加溶解方向、加第二张细节贴图都特别方便。
1.2 动态着色的核心是什么
所谓"动态着色",我理解下来其实就是在Shader运行过程中,通过外部传入的参数(通常是一个float变量)不断改变贴图采样结果,让同一个材质在每一帧呈现出不同的颜色和透明度状态。它和静态贴图的本质区别是,颜色不是一成不变的,而是被时间、脚本、事件驱动着变化。
拿消融来说,核心动态参数就是消融进度(Dissolve Amount)。这个值一般从1渐变到0,或者从0渐变到1,取决于你定义的是"融化掉多少"还是"剩余多少"。我们采样噪声贴图后,可以给采样结果做各种映射,比如让黑白噪声变成有层次的色阶,再和阈值比较,决定像素去留。
我还见过很多新手问"为什么我的消融是整体变透明而不是不规则消失",这就是因为Shader里直接用了alpha通道做clip,但alpha在模型UV上是均匀的,没有噪声扰动,所以整片整片地消失。正确的思路应该是:把一张噪声贴图或者高度图放到Shader里,用它作为"哪个地方先消失、哪个地方后消失"的依据。这样物体的消失是带不规则边缘的,观感上才像真实的燃烧、融化。
2. Unity Shader从零实现基础消融
2.1 准备工程与Shader结构
在Unity里新建一个标准3D工程,然后把默认的Standard Shader相关的材质先放一边,我们直接创建一个Shader文件(Create -> Shader -> Unlit Shader),命名为DissolveEffect。双击打开后,默认代码会是一个带主贴图的基本Unlit Shader,我们把它改造成消融版本。
先说一下Shader的整体框架。一个能在Unity里用的Shader至少包含Shader{}、Properties{}、SubShader{}、Pass{}这几层。Properties是暴露给Inspector面板的参数,SubShader是为了兼容不同渲染平台(比如真机和高版本平台可以各写一套),Pass里才是真正的顶点片元着色逻辑。
我习惯把消融相关参数都写在Properties里,这样美术同事直接在材质面板上调参数,不用碰代码:
Properties { _MainTex ("RGB颜色贴图", 2D) = "white" {} _NoiseTex ("噪声贴图(控制消融形状)", 2D) = "white" {} _DissolveAmount ("消融进度", Range(0, 1)) = 0 _DissolveRange ("消融边缘宽度", Range(0, 0.5)) = 0.05 _EdgeColor ("消融残影颜色", Color) = (1, 0.4, 0, 1) }这里面_NoiseTex是整个效果的关键。它不需要是高清贴图,一张128x128或者256x256的灰阶噪声图就够用。噪声上那些不规则的斑点、纹理,会成为消融边缘混乱感的来源。如果美术那边没有合适的噪声图,直接在Photoshop里生成云彩滤镜,或者用Unity自带的PerlinNoise算法程序化生成一张,都可以。
2.2 采样、裁剪与阈值渐变
进入Pass内的片元函数frag,真正决定消融效果的核心逻辑其实只有五六行:
fixed4 frag (v2f i) : SV_Target { // 采样颜色贴图,得到物体本来的颜色 fixed4 col = tex2D(_MainTex, i.uv); // 采样噪声贴图,得到每个像素的"消失优先级" float noise = tex2D(_NoiseTex, i.uv).r; // 把阈值和噪声做差,小于0的部分直接丢弃 clip(noise - _DissolveAmount); return col; }我逐行讲一下。_DissolveAmount是外部传入的消融进度,初始设为0时,noise - 0基本都是正值,所以所有像素都保留,物体显示完整;当_DissolveAmount逐渐增大到1时,大多数噪声值小的像素被丢弃,物体就慢慢镂空消失了。噪声贴图的作用,就是把"裂缝从哪里开始"变成每个像素各有不同的规则,而不是均匀地从边缘开始剥落。
这里有几个细节值得注意。第一,clip(noise - _DissolveAmount)这个写法是跳过光照的简单Unlit方案,如果你想物体受到光照,需要再算上一套漫反射和光照模型,比如用Lambert或者放到Standard管线里。第二,噪声值建议取RGB通道里的R通道就好,没必要三个通道都采样,毕竟我们只关心灰度变化。第三,裁掉的像素是彻底的消失,不会留下半透明残影,如果你想要那种"烧焦边缘还带着火星余烬"的感觉,需要在边缘区域单独叠加颜色——下面第三部分我会详细讲。
Shader写好后,在场景里放一个立方体或者导入一个角色模型,把材质拖上去。在材质的Instpector面板里可以看到DissolveAmount的滑动条,手动拖动它,物体就会开始扭曲、消失。到这一步,一个可用的基础消融效果已经完成了。
3. 动态着色进阶:边缘发光与燃烧质感
基础消融的效果是"物体被挖掉一块",但说实在的,观感比较干。游戏里让人眼前一亮的消融,几乎都带边缘效果——比如烈焰烧灼的亮边、冰块融化的水渍光边。这一节的思路就是:在噪声值和阈值非常接近的地方(即即将消失的边界),输出一个额外的亮色,让边缘区域发光发亮。
3.1 边缘发光的算法思路
利用前面采样的noise和阈值_DissolveAmount,我们可以算出距离被裁剪的临界值还有多远。通俗说,我们判断某个像素是否处在边缘带里。
代码可以这样扩展:
float edge = smoothstep(_DissolveAmount, _DissolveAmount + _DissolveRange, noise); float edgeFactor = 1.0 - saturate(edge);_DissolveRange是边缘带的宽度。当noise值非常接近阈值时,edgeFactor接近1,说明这个像素正处于消融边缘;当noise远超阈值时,edgeFactor接近0,说明这个像素还很"安全"。然后用这个edgeFactor去混合_EdgeColor:
col.rgb = lerp(col.rgb, _EdgeColor, edgeFactor);实际操作时,我还喜欢在边缘色上叠加一个pow处理,让亮边更锐利:
edgeFactor = pow(edgeFactor, 0.5);如果是烧灼效果,就让_EdgeColor配合一个从左到右渐变的贴图,模拟温度从中心向边缘递减的感觉。如果你做的是冰融效果,可以把_EdgeColor调成淡蓝色,再配合半透明白色噪声,效果会非常像"冰层从内部裂开、表面渗水"。
3.2 第二种消融玩法:噪声扰动与方向性消融
除了最基础的"全局一起消失",实际项目中还经常要处理"从下往上消失""从中心向外扩散"这类需求。比如机器人从脚下开始被分解成光点,或者角色进入传送门后从头顶开始化成粒子。这就要给Shader加入方向控制。
做法不复杂:在片元函数里,我们用模型顶点的本地坐标或者世界坐标,去影响噪声采样的结果。举个从下往上消失的例子:
float heightMask = saturate(i.localPos.y / _ModelHeight); float finalNoise = lerp(noise, 0.0, heightMask); clip(finalNoise - _DissolveAmount);简单解释一下:模型底部y=0附近heightMask接近0,finalNoise基本等于原始噪声,容易保留;模型顶部y值越大,heightMask越接近1,finalNoise被压到接近0,所以最先消失。这样物体就是从顶部开始逐渐向下融化,而不是整个随机消失。
还有一种玩法是让消融区域沿着UV方向扫过,这在3D UI或者技能特效里很常见。比如装备图标从中间撕开露出下一层属性,本质就是让_DissolveAmount配合一个基于UV.x或UV.y的坐标做权重。我做过一个项目里,装备升阶动画就是用这个方案:先用噪声把旧装备从右上角向左下角逐渐溶解掉,新装备再从反方向亮出来,玩家看着非常有"蜕变"的感受。
4. 实际项目中遇到的坑和排查技巧
这一节聊聊我在真机调试和项目交付中实际踩过的坑。你写完Shader后,在Scene视图里拖动滑动条看着没问题,不代表打包到真机上也没问题。很多消融效果翻车,都是翻在这些细节上。
4.1 模型变黑洞:背面裁剪导致镂空穿帮
第一个高频问题:模型消融到一半时,从某些角度能看到模型内部是空的,或者露馅出穿帮的背面。这其实不是Shader的逻辑错了,而是渲染时背面剔除(Cull)导致的视觉盲区。默认Shader会Cull Back,就是不渲染模型的背面面片。当正面像素被大量裁剪掉后,透过镂空处看到的背面也没有像素,内部结构就全空了。
排查思路很简单:把Pass里的Cull改为Cull Off,同时把模型的渲染改成双面渲染。这样镂空处能看到内部的面片,内部细节会保留,观感会好很多。但也要注意,双面渲染会增加一倍左右的顶点处理开销,对低端机不友好。所以更好的做法是给模型加一层"内壳"——复制一个模型,翻转法线,专门渲染内部。内壳不参与消融Shader,只作为填充结构,这样既能保持消融效果,又不会产生空心感。
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| Cull Off双面渲染 | 实现简单,直接配置 | 顶点和像素处理翻倍 | 特效模型、小型角色 |
| 内壳模型 | 性能可控,效果稳定 | 需要额外美术资源 | 大型模型、长时间消融 |
| 增加网格细分 | 消融边缘更平滑 | 顶点数激增 | 对轮廓精度要求高时 |
4.2 消融到一半整个模型发黑
第二个问题:消融进行到一半,模型表面突然发黑或者整体颜色暗沉。这多半是动态光照和clip的交互问题。当你使用clip裁剪像素时,Shader的Alpha通道通常没有被正确写入深度缓冲,或者阴影接收出现异常,导致环境光遮蔽(AO)没有正确计算,模型像被罩了一层黑纱。
解决方法有两个。一是检查Shader里是否正常声明了Tags和光照模式。如果是Unlit Shader,建议开头加上:
Tags {"Queue"="Transparent" "RenderType"="TransparentCutout"}Queue设成Transparent可以让透明裁剪过的物体在渲染顺序上排到普通不透明物体之后,减少被其他物体遮挡时看不到的问题。二是如果你用的是URP,要注意_DissolveAmount变化时,阴影贴图上也会出现一块一块的黑洞。那个黑洞其实是阴影接收区域和clip区域没同步导致的,可以设置材质的ShadowMode为Off,或者单独生成影子用的Shader变体。
4.3 CPU负担和粒子特效的内存泄露
消融效果本身是GPU计算,性能其实不高。但很多项目里,消融一旦做完,就会在物体位置上催发一个粒子特效(灰烬、火星、烟雾)。我遇到过一个玩家在局内连续击杀敌人,每次击杀都触发粒子特效,结果内存越用越高,最后卡到只有十几帧。用Profiler一查,是粒子系统的Play/Stop没有正确清理。
排查下来发现是代码里每次消融结束都只Play()特效,没有监听粒子系统的Stopped事件去回收对象。后来我封装了一个特效池,粒子播完自动回到池子里,下次复用。这里分享一个实操经验:消融Shadrer里如果有_EdgeColor相关的变量,尽量不要在C#侧每帧都修改它。如果你每帧都去material.SetColor,会对材质实例造成大量GC压力。正确做法是只在消融开始时设置一次,后面的阈值渐变用MaterialPropertyBlock来驱动,减少材质实例的复制开销。
5. 更多玩法与我的工程化经验
5.1 第二张噪声贴图与双Phase消融
如果你想做一种"先烧掉外层皮,再融化内部结构"的效果,可以考虑采样两张噪声贴图。第一张噪声控制外层消失,第二张噪声控制内部消失,两张图的阈值错开0.1~0.2的节奏。实际操作时,我会把第一张噪声明暗对比做得强烈一些,第二张做得细碎一些,先用外层粗犷的裂缝剥离外壳,再用第二张细碎噪声把内部拆成漫天粉末。
代码层面就是在Shader里多声明一张_NoiseTex2,然后在片元函数里对两次clip结果做AND逻辑:
float noise1 = tex2D(_NoiseTex, i.uv).r; float noise2 = tex2D(_NoiseTex2, i.uv).r; clip(min(noise1 - _DissolveAmount, noise2 - (_DissolveAmount - 0.15)));注意第二张噪声的阈值慢了0.15,所以内部会晚一点开始消融。这种双Phase消融在Boss死亡、建筑崩塌这类"重场面"里特别出效果。
5.2 与URP渲染管线的适配
现在Unity新项目基本默认用URP(通用渲染管线)。URP下写Shader和内置管线的差异主要在于Include文件和光照函数。如果你直接拷贝旧管线的Shader到URP工程里,大概率会报错。URP下创建Shader的模板是Create -> Shader -> URP -> Unlit Shader,核心差异在于要#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"。
我用下来感觉URP的消融Shader更干净,因为URP的Lighting模型更模块化,很多内置变量直接有语义定义。不过有一个点坑:URP的clip配合MSAA抗锯齿时,边缘容易有白边。白边的本质是MSAA在边缘像素上有多重采样,而clip的判定是逐像素的,所以抗锯齿的混合结果里出现原始颜色和透明交替。解决办法是Shader里加一个粗锯齿的边缘平滑,或者把RT的MSAA关掉,用TAA做抗锯齿。视觉对比下来,TAA配合消融边缘反而更自然。
5.3 我的一些工程化建议
最后分享一下项目里的组织方式。我把消融Shader通常封装成一套可以共用的特效Shader,暴露出来的参数统一规范:
_DissolveAmount统一约定为0表示完整,1表示完全消失_DissolveRange控制在0.01到0.2之间,太大会显得整个物体半透明,太小则边缘锯齿明显_NoiseScale控制噪声贴图的平铺系数,根据模型大小调整,否则小模型贴图拉伸严重
C#侧,我会写一个简单的DissolveController脚本,挂在需要消融的物体上,用协程控制进度:
public class DissolveController : MonoBehaviour { public Material mat; public float duration = 1.0f; public void StartDissolve() { StartCoroutine(DissolveRoutine()); } IEnumerator DissolveRoutine() { float t = 0f; while (t < duration) { t += Time.deltaTime; float amount = Mathf.Lerp(0f, 1f, t / duration); mat.SetFloat("_DissolveAmount", amount); yield return null; } } }注意协程结束后一定要把dissolveAmount归位,避免物体消失后下一帧又突然出现。如果是池化物体,在生成前重置amount为0,否则会出现"新刷出来的怪是碎的"这种低级事故。
我个人在实际操作中的体会是,消融效果在视觉上讲究"节奏感"——玩家期待的是从某个点开始快速扩展,而不是全屏均匀消失。你在调整参数的时候,宁可把_DissolveRange调小一点,让边缘更锐利,也不要为了华丽把边缘宽度拉满,那样反而像物体在变透明,不像在溶解。做特效就是这样,技术实现是一回事,最终画面体验又是另一回事,多花点时间调参数,比多写几行Shader代码更出效果。