一个角色死亡后整个模型逐渐化作飞灰、表面像被火焰烧穿一样从边缘向中心瓦解——这种效果在游戏里经常被叫做“消融”或“Dissolve”。初看似乎挺炫,但实现路径其实很集中:要么靠Shader做动态着色,要么靠粒子系统硬堆。今天我把基于Unity的Shader实现从头到尾拆一遍,包含Shader Graph和手写代码两种路线,以及几个我实际踩过、不太容易在官方文档里搜到的坑。
这篇内容适合谁?如果你已经能搭一个最简单的Unlit Shader,但对裁剪、噪点采样、边缘光这些概念还没有串成一条线,这篇文章可以帮你把它们串起来。如果你打算在URP管线里做类似效果,我也会说明哪些节点和函数在URP下需要调整。最后附上可以直接“抄作业”的完整Shader代码和C#控制脚本。
1. 消融效果的视觉本质与Shader工作边界
1.1 一个完整的消融效果由哪三层视觉叠加而成
我们平时在游戏里看到的消融,其实不是单一机制,而是至少三层效果叠加在一起的结果。
第一层是主体裁剪。模型表面的某些区域突然消失,露出内部结构或直接镂空。这一层负责回答“哪里不见了”的问题,是整个效果的地基。
第二层是边缘渐隐。如果消失的边缘是硬切的,看起来就像模型被剪刀剪过,很假。所以需要在镂空边缘做一段半透明过渡或者烧焦渐变,让消失区域与保留区域之间有柔和的中间带。
第三层是边缘发光或灼烧色。在渐隐带上叠加高亮颜色,模拟高温烧灼感。这一层虽然只是锦上添花,但没有它效果会显得干瘪,尤其当消融速度较快时,这一层承担了大部分视觉冲击力。
有经验的开发者会在立项前先把这个效果拆成三层,再逐层实现和调参。原因很简单:如果你一开始就试图把三层全写进一个Shader里,出了问题你将很难判断是裁剪逻辑错了,还是颜色叠加错了。
1.2 为什么必须在Shader层实现,而不是用C#脚本删网格或改顶点
我见到不少初学者提出过类似问题:能不能直接在Update里遍历所有顶点,把高于某个Y坐标的顶点删除或者隐藏?理论上可行,但实际工程里几乎没人这么做。
核心原因有三个。第一,CPU遍历顶点并计算裁剪,每帧成本随模型面数线性上涨。一个几千面的角色可能还好,但如果是场景里同时有几十个敌人做消融,CPU就会明显吃紧。而把这套逻辑放到Shader里,计算发生在GPU上,并行执行,成本低得多。
第二,单纯隐藏顶点无法解决“内部结构露出”的问题。一个封闭网格即使隐藏了部分顶点,内部的面仍然存在,视觉上会出现透明穿帮。而Shader裁剪通过逐像素丢弃,能让碎片和背景完全通透,效果更接近“消融”而非“切片”。
第三,Shader方案天然支持任意复杂度模型。网格删除需要复制Mesh并在CPU上重建拓扑;Shader裁剪只需要一张噪点贴图和一句clip。从扩展性角度看,后者轻松碾压。
1.3 整个消融效果的核心公式
消融效果绝大多数实现,核心都绕不开下面这个公式:
裁剪结果 = 噪点采样值 - 消融进度值 当裁剪结果 < 0 时,像素被丢弃你可以把噪点贴图理解成一张为模型每个表面位置预先分配好的“随机阈值表”。消融进度是一个从0到1增长的全局变量。当进度值超过某个位置的噪点阈值时,这个位置被判定为“应该消失”。
这里最关键的设计选择是噪点贴图。下一节详细说。
2. 噪点图采样:随机性的核心来源
2.1 为什么不能直接调用Perlin噪声函数生成随机值
Unity里有个现成的Mathf.PerlinNoise,有C#基础的同学可能会想:能不能每帧在C#里算出所有位置的噪声值,再传给Shader?理论上思路是对的,但实际操作会撞上两堵墙。
第一堵墙是“逐像素执行”的架构。Shader的片元着色器对每个像素执行一次,你无法在Shader里方便地调用一次C#侧的函数,更不能让每个像素都在CPU侧查询同一个噪声函数的返回值——那样每帧都会有百万次以上的函数调用。GPU硬件擅长的是纹理采样,不是CPU式函数调用。
第二堵墙是UV不连续。模型展开UV后,每个片元拿到的UV坐标是连续的,但Perlin噪声的连续性与UV连续性并不是一回事。用噪声函数实时计算不是不可以,但需要考虑函数实现、频率参数、以及在不同显卡上的计算结果一致性,复杂度远超采样一张纹理。
所以行业里通行做法非常简单直接:把噪声预烘焙成一张贴图,Shader里直接采样。这张贴图可以是Perlin噪声、Voronoi图或者随便什么随机噪点,只要灰度分布足够自然即可。
2.2 噪点贴图的选择标准:连续渐变优先于细碎颗粒
噪点贴图选错是消融效果翻车的第一大原因。如果你用的是一张颗粒感极强的随机噪点图,消融边缘会呈现密集的、杂乱的小孔,看起来像被霰弹枪轰过,而不是均匀烧穿的效果。
我的建议:优先选择低频连续渐变型噪点,比如Perlin噪声图或者FBM(分形布朗运动)叠加图。这类贴图的灰度过渡自然,消融时会产生大块连续剥落的效果,更符合日常认知中的“融化”或“燃烧”。
作为对比,高频噪点贴图适合做“金属锈蚀”“碎片剥落”等细碎效果,做消融边缘太碎,不建议优先选用。
在实操层面,Unity引擎本身不提供直接导出噪点贴图的功能,但有很多免费方式可以生成。一个很常用的办法是用Photoshop、GIMP的噪声滤镜配合高斯模糊,另存为灰度图;另一个办法是使用一些在线噪声生成器直接输出Perlin噪声贴图,导入Unity时注意勾选“Generate Mip Maps”并关闭“sRGB (Color Texture)”选项——这张贴图本质上是数据纹理,不是可见纹理。
2.3 采样坐标的选择:UV、世界坐标还是第二套UV
很多第一次做消融的人默认采样UV坐标,结果在大模型上翻车:模型的某些区域UV密度不均匀,导致消融速度一块快一块慢。
如果你希望整个物体的消融速度在空间上尽量均匀,常用的替代方案是取世界坐标Y轴或模型空间坐标作为采样坐标。比如,让消融从角色脚底往头部蔓延,就可以直接用世界坐标Y值参与计算;或者干脆取世界坐标的XZ平面做噪点采样,让剥落从四周向中心推。
如果你的美术资源规范,模型自带一套覆盖全模型且面积密度均匀的第二套UV(常用于光照贴图烘焙),那么用第二套UV采样噪点图是最好的选择,因为它能保证所有表面位置都有合理映射。我处理角色死亡效果时更常使用模型坐标混合方案:把模型局部坐标的绝对值乘以缩放系数作为采样UV,效果稳定且不依赖资源规范程度。
这里还有一个容易忽略的细节:如果模型本身是一个很长的物体,比如一把长剑,UV的U方向被拉伸得很长,那么噪点图在这个方向也会被拉伸,消融碎片会变成纵向长条。解决办法是在采样前对UV乘以一个缩放系数(Tiling),或者换用世界坐标方案。
2.4 噪点缩放系数与Tiling的实际影响
Shader里通常会有一个类似_NoiseScale的属性,用来控制噪点图在模型表面重复的频率。这个值越小,噪点图被放大得越厉害,每个碎块的面积越大,消融过程越“粗颗粒”;值越大,碎块越细小,边缘越细腻。
调这个参数时要注意与模型尺寸联动。同样是1.0的缩放系数,用在拳头大小的道具上和用在两层楼高的大型怪物上,效果天差地别。我的习惯是先让美术给出模型的大致包围盒尺寸,再根据期望的碎块大小反推缩放系数,而不是每次靠肉眼猜。
3. Shader Graph快速搭建完整可用的消融
如果你想先在项目里快速看效果,Shader Graph是效率最高的路径,不需要写一行代码。这里梳理一下核心节点链路和几个容易忽略的配置。
3.1 从Texture到裁剪的主链路由哪些节点组成
Shader Graph里的主链路非常清晰,从左到右依次是:
Noise Texture节点(或者你用外置噪点图,用Sample Texture 2D节点采样)- 采样结果输出灰度值(通常取R通道,如果你的噪点图是灰度图,三个通道都一样)
- 用
Add或Multiply把缩放系数作用到UV上 - 拿灰度值与
_DissolveAmount(自定义浮点属性)做差 - 差的结果接到
Step节点或直接进入Clip节点
在Shader Graph中,Clip节点需要你提供一个“Test”值。当这个值小于0时,像素被丢弃。所以上面第4步的差值直接就作为Clip输入。
这中间最容易被忽略的是UV缩放节点。如果漏掉这一步,你会发现模型表面的噪声颗粒大小完全由模型UV密度决定,不同的模型完全无法统一效果。正确做法是在采样前加一个Tiling And Offset节点,或直接用Multiply乘_NoiseScale。
3.2 边缘发光:利用两阶段阈值差实现
Shader Graph里做边缘发光也不复杂。
方案是计算两个差值:一个是噪点值与_DissolveAmount的差,另一个是噪点值与_DissolveAmount + _EdgeWidth的差。然后把这两个差值的结果做减法或线性插值,得到一个“边缘区域遮罩”。在这个遮罩上叠加发光色,就能让边缘带亮起来。
连法大概是:
- A = Noise.r - _DissolveAmount
- B = Noise.r - (_DissolveAmount + _EdgeWidth)
- 遮罩 = 1 - smoothstep(0, _EdgeSoftness, B)
最终,主纹理颜色与边缘发光色通过遮罩混合。这段逻辑在手写Shader里大概是6-8行代码,在Shader Graph里大概是8-10个节点,无论哪种方式都不复杂。
3.3 Shader Graph实际使用中我踩过的三个配置坑
第一个坑是颜色空间。如果项目使用Linear颜色空间,而噪点图仍然被当作sRGB处理,采样出来的数值会被提亮,导致消融阈值对不上,碎块分布明显偏移。解决方法是选中噪点贴图,在导入设置里取消勾选sRGB (Color Texture)。如果是在Shader Graph的Sample Texture 2D节点里单独指定,也需要做类似处理。
第二个坑是URP兼容性。内置管线创建的Shader Graph默认使用内置管线的Lit或Unlit,一旦项目切换URP,Graph里的一半节点会报错。通用的解决思路是直接把Shader Graph的“Active Targets”改成URP,然后重新连节点。一个项目里最好统一目标管线,不要混用。
第三个坑是移动端指令数膨胀。Shader Graph生成的代码往往比手写Shader多出20%到50%的指令数。如果是PC平台问题不大,但低端Android手机上要保持60帧,我建议最终版本仍然用手写Shader。Graph定位为原型验证工具更合适。
4. 手写Shader代码逐行拆解:从Unlit开始
如果你想要最大程度的可控性和性能,最终还是要落到手写Shader上。我以Unity内置渲染管线(Build-in Render Pipeline)为基准,写一个完整可用的消融Shader,并逐段解释关键逻辑。
4.1 属性声明与Pass配置里的关键选项
Shader "Custom/DissolveEffect" { Properties { _MainTex ("Main Texture", 2D) = "white" {} _NoiseTex ("Noise Texture", 2D) = "white" {} _NoiseScale ("Noise Scale", Float) = 1.0 _DissolveAmount ("Dissolve Amount", Range(0, 1)) = 0 _EdgeWidth ("Edge Width", Range(0, 0.5)) = 0.1 _EdgeSoftness ("Edge Softness", Range(0, 1)) = 0.2 _EdgeColor ("Edge Color", Color) = (1, 0.4, 0, 1) _BurnColor ("Burn Color", Color) = (0.1, 0, 0, 1) } SubShader { Tags { "RenderType"="Opaque" "Queue"="Geometry" } Cull Off Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include "UnityCG.cginc" sampler2D _MainTex; sampler2D _NoiseTex; float4 _MainTex_ST; float4 _NoiseTex_ST; float _NoiseScale; float _DissolveAmount; float _EdgeWidth; float _EdgeSoftness; fixed4 _EdgeColor; fixed4 _BurnColor; // ... 后续代码 ENDCG } } Fallback "Diffuse" }注意几个细节。Cull Off是在模型被裁到一半时,内部背面也会显示,视觉上更完整。如果你的模型本身是单面片或角色资源有特殊裁剪需求,可以考虑改成Cull Back。RenderType和Queue保持Opaque和Geometry,因为我们用的不是透明混合,而是逐片元丢弃,这能让深度写入正常,不会像半透明那样产生排序问题。
4.2 顶点片元着色器中的完整实现
struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; float3 normal : NORMAL; }; struct v2f { float2 uv : TEXCOORD0; float3 worldPos : TEXCOORD1; float4 pos : SV_POSITION; float3 worldNormal : TEXCOORD2; }; v2f vert (appdata v) { v2f o; o.pos = UnityObjectToClipPos(v.vertex); o.uv = TRANSFORM_TEX(v.uv, _MainTex); o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz; o.worldNormal = UnityObjectToWorldNormal(v.normal); return o; } fixed4 frag (v2f i) : SV_Target { // 1. 采样主纹理 fixed4 albedo = tex2D(_MainTex, i.uv); // 2. 选择采样坐标:这里用世界坐标XZ平面 + 模型坐标Y,综合出消融位置 float2 noiseUV = i.worldPos.xz * _NoiseScale; float noise = tex2D(_NoiseTex, noiseUV).r; // 3. 主体裁剪 float dissolve = _DissolveAmount; clip(noise - dissolve); // 4. 计算边缘区域遮罩 float edgeDistance = noise - dissolve; float edgeMask = 1 - smoothstep(0, _EdgeWidth, edgeDistance); edgeMask *= step(edgeDistance, _EdgeWidth); // 5. 边缘颜色与灼烧色混合 fixed3 finalColor = albedo.rgb; finalColor = lerp(finalColor, _EdgeColor, edgeMask * 0.8); finalColor = lerp(finalColor, _BurnColor, edgeMask * 0.4); // 6. 基础光照(简单Lambert,示意用) float ndotl = dot(normalize(i.worldNormal), normalize(float3(0.5, 1, 0.25))); finalColor *= max(0.2, ndotl); return fixed4(finalColor, 1.0); }这段代码的核心在第3步。clip(noise - dissolve)里,只要noise小于dissolve,差值就小于0,GPU直接丢弃该片元。这比在C#里判断要高效得多。
第4步计算边缘遮罩的逻辑很多人第一眼会绕:edgeDistance = noise - dissolve,已经小于0的片元被clip丢弃了,剩余片元的edgeDistance都大于等于0。smoothstep(0, _EdgeWidth, edgeDistance)表示越靠近裁剪线(edgeDistance接近0),值越接近0,取反后接近1。所以靠近裁剪线的像素拿到了高遮罩值,远离裁剪线的像素遮罩值归零,自然形成了一圈边缘发光带。
4.3 关键参数速查表
| 参数 | 作用 | 推荐初始值 |
|---|---|---|
| _NoiseScale | 控制碎块大小 | 1.0,后随模型尺寸调整 |
| _DissolveAmount | 消融进度 | 0到1,由C#控制 |
| _EdgeWidth | 边缘发光带宽度 | 0.05到0.15 |
| _EdgeSoftness | 边缘渐变柔和度 | 0.1到0.3 |
| _EdgeColor | 边缘高温光颜色 | 橙色或亮黄 |
| _BurnColor | 灼烧底色 | 深红或黑褐色 |
4.4 为什么手写Shader比Shader Graph更适合线上项目
手写Shader最大的优势不是性能(虽然也有优势),而是可控性。当线上版本要做一些定制改动,比如“消融掉的角色留下半透明残影”,或者是“消融结束后触发一个粒子爆炸”,你只需要在Shader里加一个开关,或者暴露一个参数,改起来非常快。
另一个优势是调试起来更直接。写错节点图之后定位困难,但手写代码里一行clip出了错,预览窗口里立刻能看出来是裁剪阈值反了还是坐标取错了。有条件的话,强烈建议至少手写一遍完整流程,哪怕最后用回Shader Graph,这个理解会帮你省下未来大量排查问题的时间。
5. 边缘发光的三种实现思路与方案取舍
5.1 方案一:浓度差法(本文采用的实现)
浓度差法的核心是双阈值判断:用noise比dissolve大多少来定义边缘范围。这个方案的好处是思路直观、参数少、Shader指令便宜。坏处是边缘宽度受噪声频率影响较大,低频噪声会让边缘光宽度不均匀。
我实测下来,只要_EdgeWidth控制在0.05到0.15之间,视觉差异基本可以接受。这也是行业内最常用的方案。
5.2 方案二:基于法线外扩的双Pass描边法
如果美术要求消融边缘是清晰硬边,且外部有一圈轮廓光(很像卡通渲染风格),可以考虑双Pass方案:第一个Pass正常做消融和渲染,第二个Pass让顶点沿法线外扩,同时丢弃内部像素,只保留边缘一圈做颜色填充。
这个方案视觉效果更锐利,但代价是Shader Pass次数翻倍,模型顶点多时GPU开销增加。我的建议是:只有美术明确要求“锐利描边”时再上双Pass,常规写实类效果用方案一就够了。
5.3 方案三:屏幕空间后处理描边
第三种方案是让消融Shader只做裁剪,边缘光完全交给后处理(比如基于深度法线边缘检测)。优点是Shader本体极简,边缘检测效果统一,缺点是引入后处理会带来全屏开销,且做不了只在消融区域发光的局部效果。
这个方案在移动端尤其不划算。所以如果你不是做特定风格化渲染,我不推荐把后处理引入到单独的消融效果上。
6. 配合C#控制消融进度:从静态效果到动态演出
Shader写完之后还差最后一步:让消融进度(_DissolveAmount)动起来。这一步通常由C#脚本驱动。
6.1 最简控制脚本
using UnityEngine; public class DissolveController : MonoBehaviour { [SerializeField] private Material targetMaterial; [SerializeField] private float duration = 2f; private float currentAmount; private bool isDissolving; public void StartDissolve() { isDissolving = true; currentAmount = 0f; } private void Update() { if (!isDissolving) return; currentAmount += Time.deltaTime / duration; currentAmount = Mathf.Clamp01(currentAmount); targetMaterial.SetFloat("_DissolveAmount", currentAmount); if (currentAmount >= 1f) { isDissolving = false; // 这里可以触发后续逻辑:禁用碰撞、播放粒子、销毁物体等 } } }需要注意一个细节:如果多个角色共用同一个材质实例,直接targetMaterial.SetFloat会同时影响所有使用同一材质的角色。正确做法是使用MeshRenderer.material(自动实例化)或者MaterialPropertyBlock。后者性能更好,不产生材质实例,多人同屏消融时尤其推荐。
6.2 消融结束后的物体处理
消融到_DissolveAmount = 1时,因为clip因子恒小于0,整个物体会消失不见。但GameObject本身仍然存在,碰撞体如果没禁用,玩家还会撞到空气墙。所以脚本里必须挂一个回调或事件,在消融结束时禁用Collider、停止移动、播放完收尾特效后再Destroy游戏对象。
我见过一些项目直接在Update里检测currentAmount >= 1f然后立刻Destroy(gameObject),这种做法在表现上没问题,但如果消融期间有动画、音效或物理交互,立刻销毁会打断它们。建议至少等到动画和音效播放完再销毁。
6.3 多个材质球和RenderTexture带来的坑
如果模型有多个SubMesh,每种材质都需要执行一遍消融。常见做法是在C#侧获取GetComponentsInChildren<Renderer>()并逐个传播_DissolveAmount。如果你用了RenderTexture把消融预烘焙到纹理上,要注意贴图尺寸和格式问题,尽量避免实时动态生成。
7. 避坑记录与性能优化实测
最后一部分纯粹是经验总结,都是我在完整跑过几个项目后整理出来的高频问题。
7.1 坑一:UV采样坐标导致消融形态“一块一块跳变”
这个问题的典型表现是消融过程中模型表面的碎片不是平滑连续消失,而是突然整块跳掉。原因几乎都是UV拉伸或镜像导致噪点图连续性被破坏。
解决办法我前面提到过:改用世界坐标采样。但世界坐标采样也有个代价——如果模型在移动或旋转,消融区域会像贴纸一样“粘”在世界坐标上,而不是跟着模型走。这时候可以把世界坐标换成模型坐标:i.localPos = mul(unity_WorldToObject, float4(i.worldPos, 1)).xyz。不同项目需求不同,两种都保留一个开关最好。
7.2 坑二:移动端GPU对clip指令的兼容性
绝大多数GPU都支持clip,但在个别低端移动平台上,过度使用clip可能导致性能下降。这是因为clip会造成GPU的像素着色器提前退出,破坏了一些并行执行效率。
实测后我的结论是:单模型消融时完全没问题,但如果一个镜头里同时有10个以上角色正在消融,帧率会出现明显波动。优化思路有两种:一是限制同屏消融数量;二是在消融之外用discard时确保周围没有大量半透明叠加。移动端不要同时让消融特效和大面积透明特效一起出现。
7.3 坑三:噪点图的Mipmap带来的模糊问题
如果噪点图开启了Mipmap,当物体距离相机较远时,GPU会自动使用低级别Mipmap,边缘细节会明显模糊,导致消融边缘变得浑浊。
解决办法:噪点图在导入设置里关闭Mipmap(前提是物体的缩放变化不大),或者把Texture2D的AnisoLevel设置为0。这里我额外提醒:不要让噪点图参与光照计算,把它标记为Unlit使用,避免出现暗面噪点发黑的情况。
7.4 性能优化的几个实测结论
- 用
half代替float能省不少寄存器,但只在低端Android上值得优化,PC端意义不大。 - 噪点图采样次数是性能瓶颈之一。如果能用单张噪点图同时满足裁剪和边缘渐变需求,就不要再加第二张。
- 工程中尽量让消融Shader与主Shader统一,避免Shader变体数量爆炸。如果你在Shader里加了开关(比如
#pragma multi_compile),要注意变体数量与构建时间。 - 移动端建议关闭
Cull Off(如果你的模型封闭且不需要内部可见),这能减少一半左右的像素填充消耗。
7.5 调试工具推荐
Shader调试最常用的是Unity自带的Frame Debugger,可以逐Pass查看绘制内容。配合RenderDoc可以看单个像素的中间变量数值,定位“为什么这个片元被裁掉”之类的问题。如果是开发期,建议在Shader开头写一个return fixed4(noise, noise, noise, 1)之类的调试行,先确认噪点图采样值是否符合预期,再继续往下调颜色。
8. 从消融效果扩展到更多动态着色玩法
消融Shader的本质是“根据噪点值动态裁剪像素”,这个套路延伸出去可以覆盖很多效果:根据打击点位置做局部破损、根据时间做渐隐渐显、根据特定纹理通道做锈蚀蔓延。理解了裁剪和阈值控制,你等于掌握了一套通用动态着色武器。
我在项目中一般会维护一个可复用的Dissolve工具仓库,包含噪点图、C#控制器、Shader模板文件,新项目直接拖进去跑。这个习惯帮我省了很多重复工作。
最后再分享一个调参细节:边缘光颜色尽量不要用纯白色,带一点色温偏向(偏橙或偏红)会让灼烧感强很多。而焯烧底色用接近黑的深红,比纯黑更有“温度感”。这种细节美术不一定写进文档,但实机效果差别肉眼可见。