ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Unity消融效果实战:从噪声纹理、阈值裁剪到边缘光Shader实现

Unity消融效果实战:从噪声纹理、阈值裁剪到边缘光Shader实现 做动作游戏或者ARPG总绕不开一个需求怪物死亡的时候不希望它只是原地消失而是有一种“从边缘被火焰吞噬”的演出感。Unity里管这叫消融效果本质上是动态着色的一种——不是替换整张贴图而是用一个随时间变化的阈值去“刮掉”模型表面像素。有经验的同事会直接说写个dissolve就行。但dissolve看着简单真正落地牵扯到噪声纹理、裁剪指令、边缘光、阴影Pass、批处理兼容等一系列问题每一项都能让效果在真机上翻车。这篇文章我会从原理讲到手写Shader再到C#控制最后列几个我实际踩过的坑希望能帮你一次把消融效果做对。1. 消融效果不是“消融”是片元着色器在帮你做选择1.1 消融的视觉构成噪声场、阈值、边缘光三者怎么配合消融效果在游戏里非常常见角色被击飞后化为碎片、敌人死亡时化为灰烬、传送门的物体逐渐消失、Boss出场时从虚影到实体。这些效果的底层机制其实高度一致——不是在几何层面把模型删掉也不是用透明度去淡出而是在片元着色器阶段做一个“取舍”每个像素绘制前先问一个问题——“这里该不该被烧掉”这个“该不该”的判断依据来自三个东西的配合噪声场Noise Field一张噪声纹理想象成一个起伏不平的“地形”每个UV坐标对应一个高度值范围通常在0到1之间。这个值决定了表面的“脆弱程度”。阈值Threshold一个全局可调的数字。当噪声值小于阈值说明这个位置已经被“烧穿”直接丢弃像素当噪声值大于阈值保留像素。边缘光Edge Glow由于噪声场是连续的阈值线附近总有一圈“差一点被烧穿”的像素他们在视觉上构成一道亮边叠加颜色之后就是消融边缘的辉光。用生活化类比理解比如一面墙贴满便利贴每张便利贴的牢固程度不同噪声值你按时间顺序不断撕掉牢固程度低于某个标准阈值的便利贴。撕到中间时墙上会出现一圈“即将脱落”的纸边它们在风里晃动而边缘光就是给这些晃动的纸边涂上了荧光色。之所以说它是“动态着色”而不是简单的“透明度动画”是因为整个效果完全由Shader内部的时间变量或外部传入的标量驱动色彩的叠加、裁剪、发光都在GPU上同步完成不需要CPU去逐顶点或逐像素做运算。这也是为什么消融能成为动态着色入门最佳案例——它麻雀虽小五脏俱全一个简单的dissolve背后把贴图采样、算术运算、条件裁剪、颜色叠加全部串起来了。1.2 三个典型应用场景对参数的差异化要求消融效果虽然看起来差不多在不同场景下参数取向完全不一样这一点最容易被忽略。角色死亡消散通常希望它“从边缘开始最后消失”而且速度不要快。阈值从0缓慢加到1边缘宽度要给足否则看起来像模型被切掉一块而不是烧没。我常用的调试起点是阈值速度0.3/s左右、边缘宽度0.08~0.12基于UV密度、边缘光用红橙色或蓝紫色。传送/进入场景这类效果相反更需要“从无到有”的聚拢感。阈值从一开始的1逐渐降到0也就是逆向消融。视觉上强调的同样是那条扫描线一样的亮边这时噪声尺度建议调大让边缘形状轮廓更清晰。受击局部破坏个别游戏里怪物受击部位出现裂纹烧灼效果。这种不能全模型消融通常会结合第二套UV或者顶点色mask只让特定区域参与消融。实现上需要多采样一张mask纹理在Shader里把噪声与mask相乘。一个关键心得不要一上来就写Shader先想清楚你要的是“烧毁”“消散”“聚拢”还是“局部破损”。同样是clip(noise - threshold)这行核心指令参数方向、边缘光颜色、噪声形状完全不同方向选错后面所有调参都在浪费时间。2. 技术选型Shader Graph 还是手写 Unity Shader2.1 Shader GraphURP下搭一个消融节点图先聊Shader Graph因为很多项目现在用URP团队美术也习惯可视化编程。在URP工程里新建一个Unlit Shader Graph如果有光照需求用Lit但消融通常是临时性效果Unlit就够了然后按这个节点逻辑搭创建Noise节点Gradient Noise或Simple Noise输出到Step节点。添加一个Float属性DissolveThreshold连接Step的Edge参数。当Noise小于ThresholdStep返回0大于则返回1。用Branch节点若Step输出0则将该片元的Alpha设为0并丢弃。更直接的做法是在Shader Graph里打开Alpha Clipping选项然后把Step结果接到Fragment节点的Alpha和Alpha Clip Threshold上。边缘光把Noise和Threshold输入到Smoothstep节点输出作为边缘mask再乘一个EdgeColor属性加到BaseColor上。最后记得把Surface改成Transparent并且勾选Alpha Clipping。这里有一个很多人踩过的坑在Shader Graph里直接使用Alpha Clip Threshold但忘了勾选Alpha Clipping开关导致那个Threshold输入框根本不显示整个Shader透明通道失效。我第一次搭的时候漏了这一步节点图全对但模型纹丝不动从材质面板到帧调试器反复查了半天最后发现只是少勾了一个勾。Shader Graph的好处是美术可以自己调噪声Scale、边缘宽度、边缘颜色不需要懂代码。但它的缺点也明显节点多了之后性能不好预估而且URP和Built-in不能通用。如果你是个人开发者想做一个跨管线可复用的效果我还是建议手写Shader至少核心版本手写一份。2.2 一份可直接复用的手写消融Shader代码Shader Graph适合快速验证但如果你需要精确控制、需要兼容Built-in管线、需要看源码心里有底手写Shader依然是绕不开的技能。下面这份是Built-in Render Pipeline下可以直接用的版本URP里只需要把CGPROGRAM换成HLSLPROGRAM并替换对应的光照函数即可。Shader Custom/DissolveUnlit { Properties { _MainTex (Albedo, 2D) white {} _NoiseTex (Dissolve Noise, 2D) white {} _Color (Color, Color) (1,1,1,1) _DissolveThreshold (Dissolve Threshold, Range(0,1)) 0 _EdgeWidth (Edge Width, Range(0,0.5)) 0.1 _EdgeColor (Edge Color, Color) (1,0.5,0,1) _EdgeIntensity (Edge Intensity, Range(0,5)) 2 _NoiseScale (Noise Scale, Float) 1 } SubShader { Tags { RenderTypeTransparentCutout QueueAlphaTest } Cull Off Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float2 uv : TEXCOORD0; float2 noiseUv : TEXCOORD1; float4 vertex : SV_POSITION; }; sampler2D _MainTex; float4 _MainTex_ST; sampler2D _NoiseTex; float4 _NoiseTex_ST; fixed4 _Color; float _DissolveThreshold; float _EdgeWidth; fixed4 _EdgeColor; float _EdgeIntensity; float _NoiseScale; v2f vert (appdata v) { v2f o; o.vertex UnityObjectToClipPos(v.vertex); o.uv TRANSFORM_TEX(v.uv, _MainTex); o.noiseUv TRANSFORM_TEX(v.uv, _NoiseTex); return o; } fixed4 frag (v2f i) : SV_Target { // 采样噪声场 float noise tex2D(_NoiseTex, i.noiseUv * _NoiseScale).r; // 核心丢弃低于阈值的片元 clip(noise - _DissolveThreshold); // 边缘光带越接近阈值mask越大 float edge 1.0 - smoothstep(_DissolveThreshold, _DissolveThreshold _EdgeWidth, noise); fixed4 col tex2D(_MainTex, i.uv) * _Color; col.rgb _EdgeColor.rgb * edge * _EdgeIntensity; return col; } ENDCG } } FallBack Diffuse }这个Shader可以直接挂载到带Renderer的物体上。里面已经包含了噪声采样、clip裁剪、边缘光叠加三个核心逻辑没有任何多余的光照计算目标就是一个干净利落的消融效果。噪声纹理需要你在Project窗口里自己准备一张推荐灰度Perlin噪声或者碎形噪声选中纹理后把Wrap Mode改成Repeat后面会讲为什么。3. 手写Shader逐行拆解阈值、噪声、边缘光的计算链3.1 clip指令背后的渲染管线语义很多初学者会把消融效果的“消失”理解为透明度归零其实关键在于那行clip(noise - _DissolveThreshold);clip指令的语义是如果括号里的值为负数则直接丢弃当前片元不再执行后续采样、计算也不写入颜色缓冲和深度缓冲。这意味着被丢弃的区域不只是“看不见”而是根本不参与渲染性能上反而比半透明排序和混合更便宜。为什么消融不用alpha0因为半透明渲染需要处理混合顺序而且半透明对象无法写入深度两个半透明物体交叉时会出现非常难看的穿插。而cutoff式的硬裁剪没有排序问题视觉上是“硬消失”配合边缘光之后反而更有质感。这也是消融效果在移动端被广泛使用的原因——它本质上只算一个比较加一个discard成本极低比粒子系统和动画贴图节约得多。3.2 smoothstep提取边缘光带的原理与两种写法对比边缘光是消融效果真正好看的地方。假设没有这一步模型就是被一个不规则的“剪刀”剪掉了像素从有到无没有任何过渡游戏里看起来会很干。我们需要提取的是“阈值线附近一圈还没被丢弃、但离阈值很近”的区域float edge 1.0 - smoothstep(_DissolveThreshold, _DissolveThreshold _EdgeWidth, noise);拆开看smoothstep(a, b, x)会做一次带平滑的映射当x小于a时返回0大于b时返回1中间按Hermite插值平滑过渡。所以当噪声值刚好大于阈值一点点时smoothstep返回接近01减之后edge接近1边缘光最亮当噪声值远大于阈值edge接近0边缘光消失。最终效果就是阈值线附近形成了一圈宽度由_EdgeWidth控制的亮带。还有另一种常见写法是反过来的float edge smoothstep(_DissolveThreshold - _EdgeWidth, _DissolveThreshold, noise);这种写法直接在阈值左侧取带。两种数学上是等价的——都是取阈值附近一段过渡带。但第一种写法不容易出现_EdgeWidth过大导致smoothstep参数倒置的问题所以我习惯用第一种。如果你用第二种务必保证threshold始终大于edgeWidth否则当消融接近结束时边缘光带会突然膨胀成一团非常难看。3.3 阈值与噪声尺度的数学关系还有一个容易被忽略的细节阈值变化速度和噪声尺度的关系。噪声贴图的取值范围是0到1但因为很多噪声图是Perlin噪声直方图集中在中间值附近如果阈值从0线性升到1前0.2和后0.2的速度在视觉上会特别慢中间0.4~0.6变化又特别快。这会导致消融过程“先慢、后快、再慢”看起来很假。解决办法有两个在Shader里对噪声做一次remap比如常用float noise tex2D(_NoiseTex, uv).r * 0.8 0.1;把有效范围收窄、抬高让clip临界点变得不那么极端。在C#端对进度值做曲线映射不要用线性累加而是用一个AnimationCurve把0~1的progress映射到实际阈值。我在导出效果时几乎总是用第二种因为可以在编辑器里直接调整曲线不用反复改Shader。_NoiseScale对消融形状的影响也很直观Scale越大噪声场越密集烧蚀边界越细碎Scale越小边界越平缓、块状感越明显。想让怪物像纸一样从内到外燃掉Scale给1.5~2想做出岩石碎裂感Scale给0.3~0.5。这个值不是贴图属性而是采样的UV乘数改起来非常快适合运行时动态调整。4. 噪声纹理消融的“颜值”全看它4.1 选噪声贴图的几个硬性标准消融效果的观感60%取决于噪声纹理本身30%在Shader逻辑10%在脚本节奏。很多人调了半天算法疯狂微调参数结果视觉还是不对最后发现是噪声图用错了。选噪声贴图的标准灰度值分布别太极端全黑的纯色噪声会让消融变成硬边界锯齿纯白又会让clip几乎瞬间结束。理想的是灰度直方图中等偏分布目标是当阈值从0变到1时模型能在视觉上经历“边缘起光→大面积崩解→残留飘散”三个可辨识阶段。分辨率不用追求过大512或1024足够。消融边缘的细碎感主要靠UV重复次数Tiling堆出来而不是贴图分辨率。用4096的大图对画面几乎没帮助还白白占显存。Wrap Mode务必设成Repeat这是最常见的坑。如果设成Clamp噪声图边缘会出现一条颜色拉伸带消融到那个位置时会突然出现明显的“边框线”整个效果瞬间出戏。压缩格式注意移动端建议取消该贴图的sRGB采样取消sRGB选项因为噪声是算术数据而不是颜色数据。如果让它经过sRGB线性转换阈值判断会产生偏差边缘光位置会偏移。另外建议关闭Mip Map或者至少观察一下模糊噪点导致的边缘“麻绳化”。4.2 噪点、Perlin噪声、自定义mask的适用场景TV噪点/随机白噪采样出来每个像素独立跳变消融边缘非常琐碎适合做魔法消散、细小碎片崩解。Perlin/Simplex噪声连续渐变有自然的“山脉感”边缘光会形成流动的曲线纹路适合做灰烬、火焰燃烧、传送门。分形噪声多层叠加细节更丰富大形上有山脊感小形上有很多皱褶适合做岩石开裂、硬表面碎散。自定义mask手绘噪点图可以控制消融从哪里开始、朝哪个方向扩散。比如想让敌人先烧右手臂再烧身体就画一张左上黑右下白的mask纹理然后让阈值从右往左推进视觉上就会呈现从右到左的分解方向。如果没有合适的噪声贴图资源可以用C#脚本在编辑器模式下生成一张噪声图也可以直接在Shader里用顶点坐标加噪声函数实时计算。但实时噪声函数比如简单的sin随机hash在移动端的浮点差异比较大且不好预览我不建议作为默认方案——静态贴图方案既稳定又可控。5. C#端进度控制从SetFloat到MaterialPropertyBlock5.1 一个最小的消融控制脚本到这里Shader已经能跑出静态效果了接下来要让它动起来。最简单的做法是逐帧修改材质属性using UnityEngine; public class DissolveController : MonoBehaviour { [SerializeField] private Renderer targetRenderer; [SerializeField] private float dissolveSpeed 0.4f; [SerializeField] private bool autoDissolve true; private MaterialPropertyBlock _mpb; private float _progress 0f; private bool _dissolving false; private static readonly int DissolveThresholdID Shader.PropertyToID(_DissolveThreshold); private void Awake() { _mpb new MaterialPropertyBlock(); } private void Start() { if (autoDissolve) StartDissolve(); } public void StartDissolve() { _progress 0f; _dissolving true; } private void Update() { if (!_dissolving) return; _progress Time.deltaTime * dissolveSpeed; _progress Mathf.Clamp01(_progress); ApplyDissolve(_progress); if (_progress 1f) { _dissolving false; targetRenderer.enabled false; } } private void ApplyDissolve(float value) { targetRenderer.GetPropertyBlock(_mpb); _mpb.SetFloat(DissolveThresholdID, value); targetRenderer.SetPropertyBlock(_mpb); } public void ResetDissolve() { _dissolving false; targetRenderer.enabled true; ApplyDissolve(0f); } }这个脚本做了几件事如果勾选autoDissolve场景开始后自动从0加到1用MaterialPropertyBlockMPB写属性结束后关闭Renderer而不是删除GameObject方便后续复用或者对象池回收。5.2 为什么用MaterialPropertyBlock而不是直接改材质直接用material.SetFloat(_DissolveThreshold, value)在单个物体上也没问题看起来更简洁。但有两个隐藏代价material属性会实例化出一个新的材质对象放在内存里不会自动释放场景里十几个怪物同时用同一个消融材质每次调用都是多一份材质副本内存会慢慢涨上去。用renderer.material访问时会打断Unity的合批机制。即使两个物体共用同一个材质因为它们各自修改了材质实例批处理就失效了Draw Call可能翻倍。MaterialPropertyBlock的好处是属性数据跟着Renderer走不生成材质实例不会打断合批。同类物体即使消融进度不同也仍然有机会被合批。移动端或者大场景里这个差别非常明显。我只有在做单例Boss演出时才会直接SetFloat因为那时根本不care draw call而且这样写起来更快。5.3 与动画、粒子系统的联动节奏设计效果到位往往不是靠Shader而是靠时间线。我做消融时习惯把进度拆成三段而不是线性走前段0~0.3主要做“预兆”模型边缘开始出现光晕消融面积还没起来。这阶段阈值可以走慢一点配合音效或屏幕震动。中段0.3~0.75大面积崩解的高潮边缘光强度拉满粒子系统开始喷发碎片。这里阈值可以走得快一点制造“哗啦一下塌掉”的爽感。尾段0.75~1只剩最后几块“残骸”速度放慢让玩家看清最后一根柱子飘散。同时把Renderer关掉、播放一个小的爆炸音效。C#端要支持这种三段式用AnimationCurve最直观[SerializeField] private AnimationCurve dissolveCurve AnimationCurve.Linear(0, 0, 1, 1); // 在Update里推动progress float threshold dissolveCurve.Evaluate(_progress); ApplyDissolve(threshold);在Inspector里把这个曲线调成“前慢中快尾慢”的S形整个消融节奏就完全可控了。粒子系统可以挂在同一个GameObject下监听进度到0.3时Play、到1时Stop。这样一来视觉效果和音效、粒子、镜头震动都能串在一条时间线上观感会好非常多。6. 落地时最容易翻车的五个坑6.1 阴影Pass没同步裁剪地面影子“出卖”你我第一次做消融的时候琢磨了很久为什么模型表面都已经烧没了地面上的影子却还是完整的原因很简单——主Pass里的clip只影响主相机看到的颜色而阴影由另一个PassShadowCaster生成。如果没有在那个Pass里也采样噪声并做同样的clip阴影系统只会根据模型几何信息生成一个完整的阴影面片。所以完整可用的消融Shader必须把同样的裁剪逻辑加到ShadowCaster Pass里。Built-in管线一般有两种做法一种是在Shader里自己写一个ShadowCaster Pass复制同样的顶点片元逻辑另一种是改Fallback让系统调Fallback里的ShadowCaster但这样你没法把噪声采样传进去。下面是一个简化版的ShadowCaster Pass可以直接塞进前面的SubShader里Pass { Tags { LightModeShadowCaster } CGPROGRAM #pragma vertex vert_shadow #pragma fragment frag_shadow #include UnityCG.cginc sampler2D _NoiseTex; float _DissolveThreshold; float _NoiseScale; struct appdata_shadow { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f_shadow { float2 noiseUv : TEXCOORD0; float4 vertex : SV_POSITION; }; v2f_shadow vert_shadow (appdata_shadow v) { v2f_shadow o; o.vertex UnityObjectToClipPos(v.vertex); o.noiseUv v.uv * _NoiseScale; return o; } fixed4 frag_shadow (v2f_shadow i) : SV_Target { float noise tex2D(_NoiseTex, i.noiseUv).r; clip(noise - _DissolveThreshold); return 0; } ENDCG }URP里写法略有不同需要使用URP自带的光照宏和SHADOW_CASTER_FRAGMENT相关接口但核心逻辑一致阴影Pass必须和主Pass共享同一套噪声采样与阈值裁剪。忘了这一步其他部分做得再精致一个错误的阴影就能把所有演出感毁掉。6.2 SRP Batcher兼容性导致的性能回退URP项目里如果你手写的Shader没有用CBUFFER包裹材质属性SRP Batcher就不会生效。一旦场景里有几十个消融物体Draw Call会肉眼可见地上升。一个简单的判断办法Shader里的所有uniform变量除了纹理都应该放进CBUFFER里CBUFFER_START(UnityPerMaterial) float _DissolveThreshold; float _EdgeWidth; fixed4 _EdgeColor; float _EdgeIntensity; float _NoiseScale; CBUFFER_ENDShader Graph生成的Shader自动做了这一步但手写Shader需要自己注意。做多平台项目时这个区别在编辑器里不太明显真机上性能回落却非常明显。如果你发现一个场景里同时出现几十个消融对象时帧率下降严重先别急着怀疑噪声采样大概率是SRP Batcher失效了。6.3 移动端精度问题让边缘光闪烁在部分移动GPU上半精度浮点数half对smoothstep边界处理会有问题表现为边缘光在动画过程中一帧帧地闪或者出现一条一条的条纹。这通常是因为CG里默认用了低精度类型而噪声值的梯度过大。解决思路在移动端Shader中强制把噪声计算改成float精度float noise tex2D(...);不要用half。给裁剪阈值加一个极小的偏移量避免噪声值恰好落在smoothstep的奇点上例如clip(noise - _DissolveThreshold - 0.001);。如果边缘光依然闪烁把_NoiseScale从整数改成小数比如3.7而不是4让边界位置错开像素网格也能缓解。这个坑在编辑器里基本不会出现属于“真机限定”问题。发布移动包之前最好用项目的目标机型列表里的最低端设备测一遍否则很容易到QA手里才暴露。6.4 纹理Wrap Mode和压缩格式对噪声采样的影响前面提过Wrap Mode必须Repeat但还有一个冷门问题如果你的噪声纹理在导入设置里勾选了Generate Mip Maps并且在运行时LOD踩到模糊层级噪声的高频细节会被抹掉消融边缘变成一条模糊的“麻绳”。解决方案是把噪声纹理的Mip Map关闭或者把Filter Mode改成Point如果风格允许这样边缘会更锐利。另外噪声贴图建议只使用R通道不要用RGBA因为移动端纹理压缩格式对RGBA的处理可能不一致最终采样结果会偏离预期。在导入设置里把Texture Type设为DefaultsRGB取消勾选然后使用R通道作为噪声源这样能保证跨平台一致性。6.5 Queue和RenderType设置混乱带来的渲染顺序问题很多人在Shader的Tags里这样写Tags { RenderTypeOpaque QueueGeometry }这在纯不透明物体上是OK的但消融效果就算用clip在运行过程中也存在大量“其实已经被裁剪掉”的区域如果有其他半透明特效——比如粒子火苗在模型内部燃烧——渲染顺序就会出问题火苗会被模型未烧掉的体块遮挡看起来很奇怪。我的建议是设置成Tags { RenderTypeTransparentCutout QueueAlphaTest }AlphaTest队列排在Geometry之后、Transparent之前。这样既不用做半透明混合又能在透明特效之前结束不透明绘制让火苗、光效叠加在残余模型上不会产生交互遮挡错误。如果项目里大量使用URP请同时检查Render Pipeline Asset里对TransparentCutout的深度写入设置保持默认即可。到这里消融效果从原理到代码再到性能坑基本都覆盖了。最后分享一个我自己的习惯每当要调消融视觉时一定先放慢速度盯着边缘光看而不是看整体消失过程。边缘光的宽度和颜色不对后面再怎么调节奏都救不回来。先用0.05的极慢速度跑一遍确认边缘亮带均匀流畅了再把速度加回去。这个检查顺序帮我省了大量反复调参的时间。
返回列表