资讯详情

资讯详情

Unity Shader消融效果实战:噪声纹理、AlphaClip阈值裁剪与边缘发光

1. 消融效果到底在解决什么问题第一次接触消融效果是在做一个角色受击反馈的时候。当时的需求很朴素角色被击中后要有一个逐渐消失的过程不能直接SetActive(false)那样太突兀玩家会觉得角色凭空蒸发。我一开始想的是用透明度渐变把材质的Alpha从1降到0结果发现两个问题一是半透明渲染会打乱渲染队列角色身上的装备、头发这些子网格互相穿插看起来像一团糊掉的果冻二是透明渐变太干净了缺少那种被侵蚀、被吞噬的质感。消融效果Dissolve本质上解决的就是这个问题它让物体表面按照某种噪声图案逐步溶解边缘还带一圈发光的燃烧痕迹视觉上像是被高温腐蚀或者被魔法吞噬。和透明渐变最大的区别在于消融是基于阈值裁剪的——像素要么完全显示要么完全丢弃不存在半透明混合所以不会有渲染排序的麻烦。这个技术点用得非常广。游戏里角色死亡、传送、召唤、隐身、被技能命中几乎都能看到它的影子。影视级的实时渲染里怪物被酸液腐蚀、建筑被能量束瓦解也是同一套逻辑。甚至一些UI动效、粒子系统的淡出也会借用消融的思路。适合谁来参考这篇内容如果你已经写过基本的Shader知道顶点着色器和片元着色器大概在干什么但还没系统做过消融那这篇正好。如果你是完全零基础建议先去补一下Shader的基础语法和渲染管线流程不然看代码会有点吃力。我会尽量把每一步的为什么讲清楚而不是只丢一段代码让你抄。关键词里提到的噪声纹理阈值裁剪边缘发光AlphaClip这几个概念是消融效果的四根支柱后面会逐个拆开讲。先给一个整体印象消融的核心就是一句话——用一张噪声图采样出来的值和一个不断变化的阈值比较小于阈值的像素直接丢弃大于的保留再在阈值附近画一圈亮边。听起来简单但魔鬼全在细节里。2. 噪声纹理消融图案的灵魂所在2.1 为什么不能用随机数代替噪声图很多人第一反应是我直接在片元着色器里用fract(sin(dot(uv, ...)) * 43758.5453)生成随机值不就行了理论上可以但实际做出来会很难看。原因是这种逐像素的伪随机数在空间上完全不连续相邻像素的值跳变剧烈消融边缘会变成一堆散乱的噪点像电视雪花而不是那种成片侵蚀的感觉。噪声纹理的价值在于空间连续性。柏林噪声Perlin Noise或者单纯噪声Simplex Noise生成的图案相邻区域的值是渐变的所以消融时会形成一块一块的岛屿逐渐被吞掉边缘自然流畅。这就是为什么几乎所有消融效果都要配一张噪声图。选噪声图有几个讲究。第一灰度图优先因为消融只需要一个标量值用RGB三通道反而浪费采样。第二对比度要适中太低的噪声图消融起来像温水煮青蛙看不出明显边界太高的又会让消融过程跳跃感太强。第三分辨率不用太高256×256甚至128×128就够因为噪声图通常会被平铺Tiling高分辨率纯属浪费显存。2.2 噪声图的采样方式与平铺技巧采样噪声图时UV的缩放系数直接决定了消融斑块的大小。缩放系数越大噪声图被拉伸得越厉害斑块越大系数越小斑块越细碎。我一般会把这个系数暴露成材质面板上的一个滑块方便美术同学调。float2 noiseUV i.uv * _NoiseTiling; float noiseValue tex2D(_NoiseTex, noiseUV).r;这里有个容易踩的坑如果模型本身的UV有接缝噪声图在接缝处会断裂消融边缘会出现一条明显的直线。解决办法是用世界坐标或者对象空间坐标来采样噪声而不是用UV。比如float3 worldPos mul(unity_ObjectToWorld, v.vertex).xyz; float2 noiseUV worldPos.xz * _NoiseTiling;用世界坐标的好处是多个物体之间的消融图案能自然衔接不会各消各的。缺点是物体移动时噪声图案会跟着流动看起来像在穿越一片固定的噪声场。如果你的物体是静止的或者移动很慢这个方案很稳。如果物体高速移动还是老老实实用UV然后尽量让美术把UV接缝藏在看不见的地方。还有一个进阶技巧用两张噪声图叠加。一张大斑块一张小细节采样后相乘或者相加消融边缘会更有层次感不会显得单调。这个在影视级效果里很常见游戏里如果性能允许也值得上。2.3 噪声图与顶点色的配合有时候我们希望消融不是均匀发生的而是从某个部位先开始比如从脚底往上烧或者从伤口处向外扩散。这时候光靠噪声图不够需要引入一个梯度遮罩。最简单的做法是用顶点色或者一张遮罩图给每个区域一个0到1的权重然后把这个权重加到噪声值上。权重高的地方先消融权重低的地方后消融。比如从下往上的消融可以用模型空间的Y坐标归一化后作为权重float heightWeight saturate((v.vertex.y - _MinY) / (_MaxY - _MinY)); float dissolveValue noiseValue heightWeight * _GradientStrength;这样消融就会从底部开始逐渐往上蔓延。_GradientStrength控制梯度的强度设为0就退化成纯噪声消融。这个技巧在做被地面吞噬或者从伤口腐烂的效果时特别好用。3. 阈值裁剪AlphaClip的工作机制与参数调校3.1 Clip函数与AlphaClip的取舍消融的核心操作是丢弃像素。在Shader里丢弃像素有两种方式一是用clip()函数二是设置AlphaClip阈值配合AlphaToMask。两者底层其实是一回事都是把不满足条件的片元直接杀掉不写入颜色缓冲和深度缓冲。clip(x)的语义是如果x小于0就丢弃当前片元。所以消融的写法通常是float dissolveValue noiseValue - _DissolveAmount; clip(dissolveValue);_DissolveAmount从0变到1dissolveValue从正变负像素逐渐被丢弃。这里_DissolveAmount就是那个不断变化的阈值。用AlphaClip的话是在片元着色器里输出一个alpha值然后在Shader的SubShader里设置AlphaTest Comparison Greater [_Cutoff]或者用clip(alpha - _Cutoff)。两种方式性能上几乎没差别但clip()更直观也更灵活因为你可以对任意中间值做裁剪不一定是alpha。我个人的习惯是直接用clip()少一层抽象。注意clip()在移动端GPU上是有代价的。它会导致Early-Z失效因为GPU没法在片元着色器执行前就知道哪些像素会被丢弃。所以如果你的项目对性能极其敏感消融效果要控制使用范围别整个场景几百个物体同时消融。3.2 阈值曲线的设计线性、缓动与自定义_DissolveAmount从0到1的变化曲线直接决定了消融的节奏感。如果直接用线性插值消融过程会显得很机械开头和结尾速度一样。实际项目中我一般会用缓动曲线让消融有蓄力—爆发—收尾的层次。常见的几种曲线曲线类型公式效果特点适用场景线性t匀速机械感强快速消失、传送缓入t²开头慢结尾快蓄力型技能缓出1-(1-t)²开头快结尾慢自然消散缓入缓出smoothstep两头慢中间快大多数场景自定义曲线纹理采样完全可控特殊演出在C#脚本里驱动_DissolveAmount时可以用AnimationCurve来定义曲线然后每帧Evaluate后传给材质public AnimationCurve dissolveCurve; public float duration 1.5f; void Update() { float t Mathf.Clamp01(elapsed / duration); float value dissolveCurve.Evaluate(t); material.SetFloat(_DissolveAmount, value); }这样美术同学可以在Inspector里直接拖曲线不用改代码迭代效率高很多。3.3 边缘宽度与阈值的关系消融边缘的宽度本质上是由噪声值在阈值附近的分布密度决定的。如果噪声图对比度很高阈值附近的值变化剧烈边缘就会很窄反之边缘就宽。但光靠噪声图控制边缘宽度不够灵活所以我们通常会显式地定义一个_EdgeWidth参数用smoothstep在阈值附近划出一个过渡带float edge smoothstep(_DissolveAmount, _DissolveAmount _EdgeWidth, noiseValue);这里edge在0到1之间0表示完全消融1表示完全保留中间是过渡带。然后可以用1 - edge来作为边缘发光的强度。_EdgeWidth越大发光带越宽视觉上越燃烧越小越像锋利的切割。我实测下来_EdgeWidth在0.05到0.15之间比较好看。太小了发光几乎看不见太大了整个物体都在发光失去边缘的意义。4. 边缘发光让消融有温度4.1 发光颜色的选择与HDR消融边缘如果不发光看起来就像被橡皮擦擦掉一样很平淡。加一圈发光立刻就有被高温烧蚀或者被能量瓦解的感觉。发光颜色通常选暖色系橙红、金黄、亮白。如果是魔法类效果也可以用青蓝、紫红。关键是发光颜色要和主体颜色形成对比否则边缘融进主体里看不出来。在HDR管线里发光颜色可以设置超过1的强度配合Bloom后处理会有明显的辉光溢出。比如float3 emissionColor _EdgeColor.rgb * _EdgeIntensity;_EdgeIntensity可以设到2到5甚至更高。在LDR管线里超过1的部分会被截断所以强度要控制在1以内靠Bloom来补。4.2 发光带的形状控制发光带的形状由edge值决定。前面用smoothstep算出来的edge在过渡带内从0变到1那么1 - edge就是一个从1变到0的斜坡。直接拿它当发光强度发光带是线性衰减的看起来有点糊。想要更锐利的发光带可以对1 - edge再做一次幂运算float glow pow(1 - edge, _GlowPower);_GlowPower大于1时发光带会向阈值一侧收缩变得更细更亮小于1则更宽更柔。我一般用2到3出来的效果比较燃。还有一种做法是用双层发光一层宽而暗的底光一层窄而亮的高光。两层叠加层次感立刻上来float glowWide pow(1 - edge, 1.5) * 0.5; float glowTight pow(1 - edge, 4.0) * 2.0; float3 finalGlow _EdgeColor.rgb * (glowWide glowTight);这个技巧在做Boss死亡、大招释放这种需要炸裂感的场合特别管用。4.3 发光与主体颜色的混合方式发光算出来之后怎么和主体颜色混合常见的有三种加法混合finalColor baseColor glow。发光会叠加在主体上亮部更亮适合发光很强的场景。插值混合finalColor lerp(baseColor, edgeColor, glow)。发光会替换主体颜色适合发光颜色和主体差异大的场景。屏幕混合finalColor 1 - (1 - baseColor) * (1 - glow)。效果介于两者之间亮部叠加暗部保留。我一般用加法混合因为最符合发光的物理直觉。但如果主体本身很亮加法会过曝这时候改用插值更稳。提示发光部分通常不需要参与光照计算直接在片元着色器最后加上去就行。如果你用的是Surface Shader可以在finalcolor或者emission里处理。5. 完整Shader实现与逐行拆解5.1 属性块与渲染状态设置先把材质面板上要暴露的参数列出来Properties { _MainTex (Main Texture, 2D) white {} _BaseColor (Base Color, Color) (1,1,1,1) _NoiseTex (Noise Texture, 2D) white {} _NoiseTiling (Noise Tiling, Float) 1.0 _DissolveAmount (Dissolve Amount, Range(0,1)) 0.0 _EdgeWidth (Edge Width, Range(0.001, 0.5)) 0.1 _EdgeColor (Edge Color, Color) (1, 0.5, 0, 1) _EdgeIntensity (Edge Intensity, Range(0, 10)) 3.0 _GlowPower (Glow Power, Range(0.5, 8)) 2.5 }渲染状态方面消融效果通常用不透明队列Geometry因为clip()丢弃像素后剩下的像素是完全不透明的不需要混合。如果用了半透明混合反而会引入排序问题。所以SubShader的Tags和Pass设置保持默认的不透明即可。如果需要双面渲染比如消融后能看到物体内部可以加Cull Off。但要注意双面渲染会让片元着色器执行次数翻倍性能敏感的场景慎用。5.2 顶点着色器UV与法线的传递顶点着色器的工作很标准把顶点从模型空间转到裁剪空间把UV和法线传下去。v2f vert (appdata v) { v2f o; o.vertex UnityObjectToClipPos(v.vertex); o.uv TRANSFORM_TEX(v.uv, _MainTex); o.noiseUV v.uv * _NoiseTiling; o.worldNormal UnityObjectToWorldNormal(v.normal); return o; }这里noiseUV直接用UV乘以平铺系数没有用世界坐标是因为大多数情况下UV采样够用而且性能更好。如果遇到接缝问题再换成世界坐标方案。5.3 片元着色器从采样到裁剪的完整链路片元着色器是核心按顺序做四件事采样主体颜色、采样噪声、计算裁剪、计算发光。fixed4 frag (v2f i) : SV_Target { // 1. 采样主体 fixed4 baseColor tex2D(_MainTex, i.uv) * _BaseColor; // 2. 采样噪声 float noiseValue tex2D(_NoiseTex, i.noiseUV).r; // 3. 计算裁剪 float dissolveValue noiseValue - _DissolveAmount; clip(dissolveValue); // 4. 计算边缘发光 float edge smoothstep(_DissolveAmount, _DissolveAmount _EdgeWidth, noiseValue); float glow pow(1 - edge, _GlowPower); float3 emission _EdgeColor.rgb * _EdgeIntensity * glow; // 5. 混合输出 baseColor.rgb emission; return baseColor; }逐行看第1步采样主体纹理并乘以基础色这是常规操作。第2步只取噪声图的R通道因为灰度图三通道值一样取一个就够。第3步是关键noiseValue - _DissolveAmount小于0就丢弃。第4步用smoothstep算出过渡带再用pow收缩发光带。第5步把发光加到主体颜色上。这里有个细节smoothstep的第二个参数是_DissolveAmount _EdgeWidth意味着过渡带在阈值上方。为什么不在下方因为阈值下方的像素已经被clip掉了根本执行不到这里。所以过渡带只能在阈值上方也就是还没被消融但即将被消融的区域。5.4 阴影与深度处理的注意事项消融效果有一个容易被忽略的问题阴影。如果物体投射阴影消融后阴影不会跟着消失因为阴影贴图的渲染用的是另一个Pass。解决办法是在阴影Pass里也加上同样的clip逻辑。如果你用的是Surface Shader可以在addshadow指令下自动处理。如果是手写Shader需要自己写一个ShadowCaster Pass在里面重复裁剪逻辑Pass { Tags { LightMode ShadowCaster } // ... 顶点着色器 ... fixed4 frag (v2f i) : SV_Target { float noiseValue tex2D(_NoiseTex, i.noiseUV).r; clip(noiseValue - _DissolveAmount); return 0; } }深度PassDepthOnly同理如果项目用了深度预渲染也要加上裁剪。不然会出现物体已经消融了但深度图里还在导致后处理效果错乱的问题。6. 性能优化与常见坑位排查6.1 移动端的裁剪开销与合批问题前面提过clip()会让Early-Z失效这在移动端Tile-Based GPU上影响更明显。因为移动端GPU的带宽很宝贵Early-Z能省下大量被遮挡像素的着色开销一旦失效overdraw就会直接体现为发热和掉帧。优化思路有几个。第一控制消融物体的屏幕占比别让一个消融效果铺满全屏。第二消融期间关闭不必要的Pass比如反射、折射这些。第三如果消融是短暂的比如1秒内可以接受这点开销如果是持续状态比如隐身考虑用其他方案比如顶点偏移配合透明。合批方面消融材质如果每个物体用不同的_DissolveAmount就会打断SRP Batcher或者动态合批。解决办法是用MaterialPropertyBlock来传参而不是给每个物体创建独立材质MaterialPropertyBlock mpb new MaterialPropertyBlock(); mpb.SetFloat(_DissolveAmount, value); renderer.SetPropertyBlock(mpb);这样所有物体共享同一个材质合批不会断。但要注意MaterialPropertyBlock在SRP Batcher下支持有限具体要看管线版本。6.2 消融边缘的锯齿与抗锯齿方案clip()是硬裁剪边缘天然有锯齿。在低分辨率下尤其明显消融边缘像狗啃的一样。几种缓解方案提高渲染分辨率最直接但代价大。开启MSAA多重采样抗锯齿对几何边缘有效但对clip产生的边缘效果有限因为clip是在片元级别丢弃MSAA的采样点可能都被丢弃了。后处理抗锯齿FXAA、TAA这类后处理抗锯齿对消融边缘有一定改善但TAA可能会有拖影。软化边缘把clip换成alpha blend在边缘做半透明过渡。但这会引入排序问题只适合单个物体或者排序可控的场景。我实测下来如果项目本身开了TAA消融边缘的锯齿基本可以接受。如果没开建议至少开FXAA或者在消融边缘加一点噪声扰动把锯齿打散成噪点视觉上反而更自然。6.3 消融与透明材质的冲突如果主体本身是半透明的比如玻璃、能量护盾消融逻辑要小心。因为半透明物体本来就在透明队列clip之后剩下的像素还是半透明的排序问题依然存在。这种场景我一般建议分层处理把消融的边缘发光单独用一个不透明的Pass渲染主体半透明部分用另一个Pass。或者干脆放弃clip改用Alpha渐变接受排序问题但通过调整渲染队列来缓解。还有一种情况是消融后要露出内部结构比如角色消融后看到骨骼。这时候需要内部模型和外部模型用不同的材质外部消融到一定程度后内部模型才开始显示。这个用_DissolveAmount的阈值分段控制就行不算复杂但要注意两个模型的消融节奏要匹配不然会穿帮。6.4 常见问题速查表现象可能原因排查方向消融边缘是直线UV接缝处噪声断裂改用世界坐标采样噪声消融时阴影不消失阴影Pass没加裁剪补ShadowCaster Pass边缘发光看不见发光强度太低或边缘太窄调大_EdgeIntensity或_EdgeWidth消融过程跳跃噪声图对比度太高换低对比度噪声图或调曲线移动端掉帧严重clip导致Early-Z失效减少消融物体数量或屏幕占比合批被打断每个物体独立材质改用MaterialPropertyBlock边缘锯齿明显硬裁剪低分辨率开抗锯齿或软化边缘这张表是我自己踩坑踩出来的基本上遇到问题对着查一遍八九不离十。7. 从消融延伸出去的几个玩法消融本身是个基础效果但它的思路可以延伸出很多变体。反向消融Materialize把_DissolveAmount从1变到0物体从虚无中逐渐显现。传送、召唤、复活这些场景都能用。实现上完全一样只是驱动方向相反。定向消融前面提到的梯度遮罩可以让消融从特定方向开始。除了Y轴还可以用X轴、Z轴甚至用一张遮罩图定义任意方向。做被激光切割或者从中心炸开的效果很方便。噪声动画给噪声图的UV加一个随时间变化的偏移消融图案会流动起来看起来像被活物吞噬。这个在恐怖游戏里特别带感。多阶段消融用多张噪声图或者多个阈值让物体分阶段消融。比如先烧掉外层皮肤再烧掉肌肉最后露出骨骼。每阶段用不同的边缘颜色层次感非常强。消融粒子在消融边缘发射粒子模拟火星、灰烬、能量碎片。粒子系统可以用Shader里的_DissolveAmount来驱动发射率消融越剧烈粒子越多。这个组合做出来效果直接上一个档次。我自己在实际项目里最常用的是反向消融和定向消融因为这两个最容易和游戏逻辑结合。比如角色复活时从地面升起用Y轴定向的反向消融配合一点粒子演出效果就很到位代码量也不大。最后分享一个小技巧消融的_DissolveAmount不要直接用Time.time驱动那样每次播放的节奏都一样看多了会腻。可以用Mathf.PerlinNoise给阈值加一点随机扰动或者每次播放时随机化曲线的起始偏移让每次消融都有细微差别。这个改动很小但玩家能感觉到活的东西和死的东西的区别。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →