1. 从"物体凭空消失"说起:消融效果到底在做什么
第一次在游戏里看到角色被击中后身体从边缘开始碎裂、像被火焰舔舐一样逐渐消失,或者场景切换时整面墙像被无形的虫子啃食一样露出空洞,我盯着屏幕愣了好几秒。那种效果不是简单的透明度渐变,也不是模型直接隐藏,而是一种带有随机性、有纹理细节、边缘还发着光的"溶解"过程。后来自己开始写Shader才明白,这就是消融效果(Dissolve Effect),而实现它的核心工具之一,就是噪声纹理(Noise Texture)。
消融效果的本质,是让物体表面按照某种规则"逐像素地消失"。最简单的做法是设定一个阈值,当某个值低于阈值时就把该像素裁掉。但如果这个"某个值"直接用UV坐标或者某个固定方向来计算,消失的边缘就会非常规整——要么是一条直线扫过去,要么是一个圆从中心扩散,看起来非常机械,完全没有"被腐蚀"的自然感。这时候噪声纹理就派上用场了:噪声纹理的每个像素值都是随机但连续的,用它作为判断依据,消失的边缘就会呈现出不规则的、有机的形态,像真实的腐蚀、燃烧或风化。
这篇笔记围绕"使用噪声实现消融效果"展开,我会把整个思路拆开讲清楚:噪声纹理为什么能产生自然边缘、clip函数在背后做了什么、边缘发光效果怎么叠加、以及在实际项目中我踩过的那些坑。适合已经了解Shader基础语法、写过简单顶点/片元着色器、想进阶做特效的读者。如果你还没接触过噪声纹理,也不用担心,我会从它的基本特性讲起。
提示:消融效果在URP、Built-in、HDRP管线下的具体写法有差异,本文以通用片元着色器逻辑为主,具体管线适配会在关键处标注。
2. 噪声纹理:让消失边缘"长"出自然形态的关键
2.1 为什么不能用普通贴图代替噪声
很多人第一次做消融,会想当然地拿一张普通的纹理(比如角色贴图、砖墙贴图)来当阈值参考。结果发现边缘虽然不规则,但明显能看出贴图本身的图案轮廓——比如用砖墙贴图,消失边缘就呈现出砖块的形状,非常出戏。原因在于普通贴图是有明确语义和结构的,它的像素值分布不均匀,某些区域整体偏亮、某些区域整体偏暗,导致消融在亮区先发生、暗区后发生,形成大块的图案化差异。
噪声纹理则不同。它的设计目标就是没有语义、没有结构、像素值在空间上均匀且连续地随机分布。常见的Perlin噪声、Simplex噪声、Worley噪声都属于这一类。它们的值在0到1之间平滑变化,相邻像素之间不会突变,但整体上又没有可辨认的图案。用这种纹理作为阈值参考,消融边缘就会呈现出均匀的、有机的、像自然腐蚀一样的形态。
我通常会用一张灰度噪声图,把它平铺到物体表面。如果物体UV展开比较规整,直接采样UV即可;如果UV有拉伸或者接缝,就需要考虑用世界坐标或三平面映射来采样噪声,避免消融边缘在接缝处出现断裂。
2.2 噪声纹理的采样与缩放:控制消融"颗粒感"
采样噪声时,UV的缩放系数直接决定了消融边缘的颗粒大小。缩放系数越大,噪声被拉伸得越厉害,消融边缘的斑块就越大、越粗糙;缩放系数越小,噪声重复得越密集,边缘就越细碎、越像细沙。
// 片元着色器中采样噪声 float noise = tex2D(_NoiseTex, i.uv * _NoiseScale).r;这里_NoiseScale我一般会暴露成材质参数,方便美术同学在编辑器里实时调整。实测下来,对于角色大小的模型,_NoiseScale在3到8之间比较合适;对于大型场景物件,可能需要调到15以上,否则噪声斑块会大到看不出细节。
还有一个细节:噪声纹理的Wrap Mode要设成Repeat,否则UV超出0-1范围后会出现边缘拉伸。如果噪声图本身有接缝(tileable做得不好),平铺后会在接缝处出现明显的直线,消融边缘就会有一条突兀的裂痕。所以选噪声图时,一定要确认它是可平铺的(seamless/tileable)。
2.3 噪声值的分布对消融节奏的影响
噪声纹理的像素值分布并不是严格均匀的。Perlin噪声的值往往集中在0.5附近,极端值(接近0或1)比较少。这意味着如果你直接用噪声值跟阈值比较,消融在大部分区域会"卡"在中间值附近,前期和后期变化快,中间变化慢,节奏不均匀。
解决办法有两个:一是对噪声值做重映射(remap),把它拉伸到更均匀的分布;二是用对比度调整,比如pow(noise, 0.5)可以让值整体偏大,pow(noise, 2.0)让值整体偏小。我一般会在Shader里加一个_NoiseContrast参数,用pow来调节,让美术能控制消融的"爆发感"。
float noise = tex2D(_NoiseTex, i.uv * _NoiseScale).r; noise = pow(noise, _NoiseContrast); // _NoiseContrast > 1 让消融更集中3. clip函数与阈值比较:消融的"开关"逻辑
3.1 clip函数到底做了什么
消融的核心操作是"丢弃像素"。在片元着色器里,clip(x)函数的作用是:如果x小于0,就丢弃当前片元,不写入任何颜色和深度。这个操作发生在片元着色器的早期,被clip掉的像素不会参与后续的混合、深度测试写入(实际上深度测试已经过了,但颜色不写入)。
float threshold = _DissolveAmount; // 0到1,由脚本控制 float noise = tex2D(_NoiseTex, i.uv * _NoiseScale).r; clip(noise - threshold);这段代码的逻辑是:当noise < threshold时,noise - threshold为负,该像素被丢弃。随着_DissolveAmount从0增加到1,越来越多的像素满足丢弃条件,物体就逐渐"消失"了。
这里有个容易混淆的点:_DissolveAmount越大,物体消失得越多。因为阈值越高,需要噪声值超过阈值才能保留,而噪声值大部分在0.5附近,所以阈值超过0.5后消失速度会明显加快。这也是为什么前面要调节噪声分布——否则消融后期会突然"哗"地一下全没了,缺乏过渡。
3.2 阈值方向与消融方向的控制
默认写法是clip(noise - threshold),物体从噪声值低的区域开始消失。如果你想让物体从某个特定方向开始消融(比如从下往上),可以把方向信息叠加到阈值里:
float direction = i.uv.y; // 从下往上 float threshold = _DissolveAmount * 2.0 - direction * _DirectionInfluence; clip(noise - threshold);这样阈值就带上了方向偏移,消融会优先在方向值低的区域发生。_DirectionInfluence控制方向影响的强度,设为0就退化成纯噪声消融。这个技巧在做"从地面升起的火焰烧毁物体"或者"从顶部开始崩解"时特别有用。
3.3 clip与alpha blend的区别:为什么不用透明度
有人会问:为什么不用alpha渐变来实现消失?把alpha从1降到0,物体不就慢慢透明了吗?确实可以,但效果完全不同。alpha渐变是整体均匀地变淡,物体轮廓始终存在,只是越来越透明,看起来像"幽灵化"。而消融是像素级别的硬边丢弃,物体表面出现真实的空洞和缺口,边缘锐利,视觉冲击力强得多。
另外,alpha blend会带来渲染顺序问题:半透明物体需要排序,和场景中其他半透明物体混合时容易出现穿帮。clip丢弃的像素完全不写入,不参与混合,没有排序问题,性能上也更友好(虽然clip本身在移动端有性能开销,后面会讲)。
注意:clip操作会打断GPU的早期深度测试优化。因为片元着色器执行了clip,GPU无法在着色前就确定哪些像素会被丢弃,必须等片元着色器跑完。所以大量使用clip的物体在移动端可能成为性能瓶颈。如果目标平台是移动端,建议控制消融物体的数量和屏幕占比。
4. 边缘发光:让消融"看起来在燃烧"
4.1 边缘检测的基本思路
纯clip的消融边缘是硬切的,看起来像被剪刀剪掉,缺乏"能量感"。加上边缘发光后,消失的边界会呈现出一条亮带,像被高温灼烧或能量侵蚀,效果立刻提升一个档次。
边缘检测的思路是:找出那些"刚好在阈值附近"的像素。这些像素的噪声值接近阈值,处于即将被丢弃的临界状态。我们可以计算噪声值与阈值的差值,差值越小,说明越靠近边缘。
float noise = tex2D(_NoiseTex, i.uv * _NoiseScale).r; float threshold = _DissolveAmount; float edge = noise - threshold; // 边缘宽度控制 float edgeWidth = _EdgeWidth; // 比如0.1 float edgeFactor = 1.0 - smoothstep(0.0, edgeWidth, edge); // 边缘发光颜色 float3 emission = _EdgeColor.rgb * edgeFactor * _EdgeIntensity; clip(edge);edgeFactor在边缘处接近1,远离边缘迅速衰减到0。用smoothstep而不是线性插值,是为了让亮带更集中、更锐利。_EdgeWidth控制亮带宽度,太宽会显得糊,太窄又看不清,一般0.05到0.15之间比较合适。
4.2 边缘颜色的叠加方式
边缘发光颜色怎么叠加到最终输出上,有几种选择。直接加到自发光(Emission)通道是最常见的,这样边缘会受光照影响较小,始终明亮。也可以加到Albedo上,但会被场景光照调制,暗处可能看不清。
// 最终颜色 float3 finalColor = albedo * lighting + emission; return float4(finalColor, 1.0);如果项目用了HDR管线,边缘颜色可以设置成超过1的强度,配合Bloom后处理,边缘会泛出光晕,效果非常带感。我一般会把_EdgeIntensity暴露出来,让美术在1到5之间调,配合Bloom的阈值来达到理想效果。
4.3 边缘发光的动态变化
静态的边缘发光已经不错,但如果让边缘颜色或强度随时间脉动,会有"能量不稳定"的感觉。比如用sin(_Time.y * _PulseSpeed)来调制强度,或者让边缘颜色在两种颜色之间插值。
float pulse = 0.5 + 0.5 * sin(_Time.y * _PulseSpeed); float3 edgeColor = lerp(_EdgeColorA.rgb, _EdgeColorB.rgb, pulse);这个技巧在做法术特效、能量护盾破碎时特别好用。不过要注意,_Time.y是持续增长的,长时间运行后浮点精度可能下降,导致脉动出现跳变。如果项目需要长时间运行,建议对时间做取模处理。
5. 完整Shader实现与参数暴露
5.1 属性块与材质参数设计
把前面所有元素整合起来,一个完整的消融Shader需要暴露这些参数:
| 参数名 | 类型 | 作用 | 推荐范围 |
|---|---|---|---|
| _MainTex | 2D | 物体基础贴图 | - |
| _NoiseTex | 2D | 噪声纹理 | - |
| _NoiseScale | Float | 噪声平铺缩放 | 3~15 |
| _NoiseContrast | Float | 噪声对比度 | 0.5~3 |
| _DissolveAmount | Range(0,1) | 消融进度 | 0~1 |
| _EdgeWidth | Range(0,0.5) | 边缘宽度 | 0.05~0.15 |
| _EdgeColor | Color | 边缘颜色 | - |
| _EdgeIntensity | Float | 边缘强度 | 1~5 |
属性块写法:
Properties { _MainTex ("Main Texture", 2D) = "white" {} _NoiseTex ("Noise Texture", 2D) = "white" {} _NoiseScale ("Noise Scale", Float) = 5.0 _NoiseContrast ("Noise Contrast", Float) = 1.0 _DissolveAmount ("Dissolve Amount", Range(0,1)) = 0.0 _EdgeWidth ("Edge Width", Range(0,0.5)) = 0.1 _EdgeColor ("Edge Color", Color) = (1,0.5,0,1) _EdgeIntensity ("Edge Intensity", Float) = 2.0 }5.2 片元着色器的完整逻辑
fixed4 frag (v2f i) : SV_Target { // 基础颜色采样 fixed4 albedo = tex2D(_MainTex, i.uv); // 噪声采样与对比度调节 float noise = tex2D(_NoiseTex, i.uv * _NoiseScale).r; noise = pow(noise, _NoiseContrast); // 阈值与边缘计算 float threshold = _DissolveAmount; float edge = noise - threshold; // 边缘发光因子 float edgeFactor = 1.0 - smoothstep(0.0, _EdgeWidth, edge); float3 emission = _EdgeColor.rgb * edgeFactor * _EdgeIntensity; // 丢弃像素 clip(edge); // 简单光照(实际项目用完整光照模型) float3 lighting = albedo.rgb; return fixed4(lighting + emission, 1.0); }这段代码是最简版本,实际项目中需要接入完整的光照模型(Lambert、Blinn-Phong或PBR)。边缘发光部分可以直接加到最终输出,不受光照影响。
5.3 脚本控制消融进度
Shader写好后,需要一个脚本在运行时控制_DissolveAmount。最简单的方式是每帧修改材质属性:
public class DissolveController : MonoBehaviour { public float dissolveSpeed = 0.5f; private Material mat; private float amount = 0f; void Start() { mat = GetComponent<Renderer>().material; } void Update() { amount = Mathf.Clamp01(amount + dissolveSpeed * Time.deltaTime); mat.SetFloat("_DissolveAmount", amount); } }注意GetComponent<Renderer>().material会创建材质实例,避免影响其他使用同一材质的物体。如果多个物体需要同步消融,应该共享同一个材质实例,用sharedMaterial或者手动管理。
提示:在URP下,如果用了SRP Batcher,频繁修改材质属性会打断合批。如果场景中有大量消融物体,建议用MaterialPropertyBlock来传递参数,或者接受合批打断的代价。
6. 实测中踩过的坑与性能优化
6.1 噪声纹理的压缩格式问题
这是我最开始踩的坑。把噪声图导入Unity后,默认压缩格式是DXT1或DXT5,这些有损压缩会改变噪声的像素值,导致消融边缘出现块状伪影——本来平滑的噪声变得有方块感,消融边缘也跟着一块一块的。
解决办法是把噪声纹理的压缩格式设为不压缩(None)或者高质量压缩,并且关闭sRGB选项(噪声是数据纹理,不是颜色纹理,不应该做gamma校正)。在Unity里,把Texture Type设为Default,sRGB (Color Texture)取消勾选,Compression设为None或High Quality。
噪声纹理导入设置: - Texture Type: Default - sRGB (Color Texture): 取消勾选 - Compression: None 或 High Quality - Wrap Mode: Repeat - Filter Mode: Bilinear6.2 移动端clip的性能开销
前面提过clip会打断早期深度测试。在移动端(尤其是使用TBDR架构的GPU),这个问题更明显。因为TBDR会把屏幕分成小块(tile),先做深度测试再着色,但clip的存在让GPU无法在着色前确定像素是否可见,必须等片元着色器执行完clip才能确定,导致大量被丢弃的像素仍然跑了完整的片元着色器。
优化思路有几个:一是控制消融物体的屏幕占比,不要让一个巨大的物体占满屏幕;二是如果消融是短暂特效,可以在消融完成后直接禁用Renderer;三是用alpha test代替clip(在某些管线里alpha test有专门的硬件优化),但alpha test的语义和clip类似,效果不一定更好。
实测数据:在一个中端移动设备上,一个占屏幕1/4的消融物体,clip带来的额外开销大约在1-2ms。如果同时有5个这样的物体,就是5-10ms,对60fps(16.6ms预算)来说压力很大。所以移动端做消融,数量控制是关键。
6.3 消融边缘的锯齿问题
clip是硬边丢弃,边缘会有明显的锯齿,尤其是物体离相机近的时候。解决办法是开启MSAA(多重采样抗锯齿),但MSAA对clip的边缘效果有限,因为clip是在片元级别丢弃,MSAA的采样点可能部分被丢弃部分保留,产生半透明边缘,反而不自然。
更好的办法是用后处理抗锯齿(FXAA、TAA)。TAA对动态边缘效果不错,但需要运动向量。如果项目已经用了后处理抗锯齿,消融边缘的锯齿基本可以接受。如果都没有,可以考虑在边缘处做一点alpha渐变,但这就回到了alpha blend的排序问题。
我的经验是:如果消融是远距离或快速发生的特效,锯齿不明显,可以忽略;如果是近距离、慢速展示,建议上TAA。
6.4 多个消融物体共享噪声的穿帮
如果多个物体用同一张噪声图、同样的UV缩放,且它们位置接近,消融图案会看起来一模一样,像复制粘贴。解决办法是给每个物体一个随机的UV偏移,或者用世界坐标采样噪声。
// 用世界坐标采样噪声,避免UV重复 float3 worldPos = i.worldPos; float noise = tex2D(_NoiseTex, worldPos.xz * _NoiseScale).r;用世界坐标的好处是每个物体在空间中的噪声采样位置不同,消融图案自然就不同。缺点是如果物体移动,消融图案会跟着"滑动",看起来像噪声在物体表面流动。对于静态物体没问题,对于移动物体需要权衡。
另一个办法是给每个物体一个随机的_NoiseOffset,加到UV上:
float2 noiseUV = i.uv * _NoiseScale + _NoiseOffset; float noise = tex2D(_NoiseTex, noiseUV).r;_NoiseOffset由脚本在Start时随机生成,这样每个物体的消融图案都不同,且物体移动时图案跟随物体,不会滑动。
6.5 消融与阴影的配合
消融物体被clip掉的像素,阴影贴图里仍然会投射阴影,导致物体已经消失了一部分,但影子还是完整的,非常穿帮。解决办法是在阴影投射的Pass里也执行同样的clip逻辑。
在Built-in管线里,需要在ShadowCaster Pass里加上相同的噪声采样和clip。在URP里,如果用了Shader Graph,需要确保阴影Pass也应用了相同的节点;如果手写Shader,需要在ShadowCaster Pass里复制clip逻辑。
// ShadowCaster Pass 中的片元着色器 float noise = tex2D(_NoiseTex, i.uv * _NoiseScale).r; clip(noise - _DissolveAmount);这个坑很隐蔽,因为在不看阴影的场景里测试时完全发现不了,一到有阴影的场景就穿帮。我第一次遇到时排查了很久,以为是阴影偏移问题,后来才发现是ShadowCaster Pass没同步clip。
7. 消融效果的扩展玩法
7.1 双向消融与"再生"效果
把_DissolveAmount从0增到1是消失,从1减到0就是再生。如果配合边缘发光,再生时边缘同样发光,看起来像物体从虚空中被"打印"出来。这个效果在角色复活、场景重建时很好用。
更进一步,可以让消失和再生用不同的边缘颜色。消失时用橙红色(燃烧),再生时用蓝白色(能量凝聚)。实现上就是根据_DissolveAmount的变化方向来插值边缘颜色,或者用两个独立的材质参数。
7.2 消融+顶点偏移:让消失更有"崩解感"
纯clip的消融是像素消失,物体形状不变。如果在消融的同时让顶点沿法线方向偏移,被消融的区域会向外"膨胀"或向内"塌缩",产生崩解感。
// 顶点着色器中 float noise = tex2Dlod(_NoiseTex, float4(v.uv * _NoiseScale, 0, 0)).r; float dissolveFactor = saturate(noise - _DissolveAmount); v.vertex.xyz += v.normal * dissolveFactor * _ExpandAmount;注意顶点着色器里不能用tex2D,要用tex2Dlod并指定mip级别。这个技巧会让物体在消融时体积略微膨胀,像被内部能量撑开,配合边缘发光效果非常炸裂。_ExpandAmount一般设得很小,0.01到0.05之间,太大就变形了。
7.3 消融与粒子系统的结合
消融边缘发光只是视觉上的"燃烧",如果再加上粒子系统在边缘发射火星或灰烬,效果会更完整。实现方式是在脚本里根据消融进度,在物体表面随机位置发射粒子。更精确的做法是用Compute Shader读取噪声纹理,找出边缘像素的世界坐标,在那里发射粒子,但复杂度较高。
简单做法是在消融开始时启动粒子系统,让粒子从物体包围盒内随机位置发射,配合消融进度调整发射速率。虽然不够精确,但视觉上已经足够,且实现成本低。
7.4 消融在UI和2D中的应用
消融不只用于3D物体,2D精灵和UI元素同样可以用。原理完全一样:采样噪声、比较阈值、clip丢弃。UI上的消融常用于按钮消失、面板切换、文字溶解等效果。
2D消融需要注意的是,UI通常用Screen Space - Overlay或Camera渲染,clip在UI Shader里同样有效。但UI的UV通常是0-1的图集UV,噪声采样要单独用一套UV,不能直接用UI的UV,否则噪声会被图集分割。解决办法是给UI元素传一个独立的UV或者用屏幕坐标采样噪声。
8. 一些个人经验与参数调优建议
调消融效果参数时,我习惯先把_DissolveAmount固定在0.5,然后调噪声缩放和对比度,找到边缘形态最自然的组合。噪声缩放太小,边缘太碎,像沙子;太大,边缘太整块,像地图。对比度太高,消融集中在少数区域爆发;太低,消融太平淡。一般_NoiseScale在5左右、_NoiseContrast在1.5左右是个不错的起点。
边缘发光的颜色,我倾向于用暖色(橙、红、黄)表现燃烧,用冷色(蓝、青、紫)表现能量或冰冻。强度不要一上来就拉满,先给1.5左右,配合Bloom看效果,再逐步加。边缘宽度0.1是个安全值,太宽会糊成一片,太窄在远距离看不清。
还有一个容易被忽略的点:消融完成后,物体的Renderer还在渲染,只是所有像素都被clip了。如果消融是永久性的,记得在脚本里检测_DissolveAmount >= 1后禁用Renderer,省下无谓的draw call。这个优化在大量物体消融的场景里能省不少性能。
最后,噪声纹理的选择上,我推荐用Perlin噪声或Simplex噪声,它们的连续性和均匀性都比较好。Worley噪声(细胞噪声)会产生块状结构,消融边缘会呈现多边形,适合做"碎裂"而不是"溶解"。如果手头没有合适的噪声图,可以用Substance Designer或在线噪声生成工具做一张,记得导出时选灰度、可平铺、无压缩。