Unity Shader消融效果从入门到实战:噪声纹理、边缘发光与移动端优化
1. 消融效果到底在解决什么问题做游戏特效的人应该都有过这种经历角色死亡、怪物消失、场景物体被“蒸发”如果直接让模型一瞬间消失画面会很生硬玩家的视线会产生明显断层。我第一次在项目里被要求做“死亡消散”效果时脑子里第一个想法就是用粒子系统堆一个爆炸但需求方说得很清楚——不是爆炸是要让模型本身像纸张被火烧掉一样从边缘开始逐渐消失而且消失的边缘要有颜色渐变和亮度变化。这就引出了消融Dissolve效果的核心逻辑它不是让物体整体消失而是通过材质级别的逐像素裁剪让模型表面按顺序、按形状地消失。配合动态着色可以让边缘呈现出燃烧、发光、腐蚀、碎裂等多种视觉风格。在Unity里做这件事主流方案就是Shader。用到的是片元着色器里的clip()函数靠一张噪声纹理对模型的每个像素做“是否绘制”的判定。实现路径不复杂但要做得好看、做得自然涉及的细节非常多噪声纹理怎么选、边缘颜色怎么采样、动态扫描怎么控制、“消融强度”这个变量怎么和游戏逻辑挂钩。这篇博文就从一个可实际落地的Shader出发把消融效果从原理到代码、从静态到动态、从单机调试到移动端适配完整走一遍。内容适合两类人看一类是刚入行、想做特效但不知道怎么下手的美术或初级开发另一类是已经被消融效果折磨过、想知道怎么优化和避坑的进阶开发者。两拨人看到的东西不太一样但都应该能从中拿走点能直接用的东西。2. 从零搭建消融Shader核心代码与逐行拆解2.1 Shader的整体框架与渲染队列选择写消融Shader之前先把渲染队列说清楚。Unity的Shader里有一个Tags块其中Queue决定了这个物体在渲染管线里属于哪个阶段。消融效果有个特殊问题物体在消失过程中边缘会变成半透明或者需要后期叠色高亮。如果还是用Geometry队列半透明部分会出现在不透明物体之后、且深度写入会对周围物体产生遮挡干扰。我的习惯是设成Tags { QueueTransparent RenderTypeTransparent }但注意这不是说一定要做半透明混合。消融Shader的裁剪本身是硬裁不涉及混合设成Transparent队列主要是为了让它和不透明物体分开排序减少裁剪边缘的视觉穿插问题。如果你只做纯硬边的“物体边消失边碎裂”也可以继续用Geometry这些后面会说各自的适用场景。再看子着色器的代码主干Shader Custom/DissolveEffect { Properties { _MainTex(主纹理, 2D) white {} _DissolveTex(消融噪声图, 2D) white {} _DissolveAmount(消融强度, Range(0, 1)) 0 _EdgeLength(边缘发光宽度, Range(0, 0.5)) 0.1 _EdgeColor(边缘颜色, Color) (1, 0.5, 0, 1) } SubShader { Tags { QueueTransparent RenderTypeTransparent } Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc sampler2D _MainTex; float4 _MainTex_ST; sampler2D _DissolveTex; float _DissolveAmount; float _EdgeLength; float4 _EdgeColor; struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float2 uv : TEXCOORD0; float4 vertex : SV_POSITION; }; v2f vert(appdata v) { v2f o; o.vertex UnityObjectToClipPos(v.vertex); o.uv TRANSFORM_TEX(v.uv, _MainTex); return o; } fixed4 frag(v2f i) : SV_Target { float noise tex2D(_DissolveTex, i.uv).r; clip(noise - _DissolveAmount); fixed4 col tex2D(_MainTex, i.uv); return col; } ENDCG } } }这段代码是整个消融效果的最小可行原型。它做的事情只有三件采样噪声纹理的红色通道得到一个0到1之间的值。把这个值跟_DissolveAmount比较如果噪声值小于消融强度就用clip()把这个片元丢弃。剩下的片元正常输出主纹理颜色。你可能会想噪声值0到1消融强度也是0到1那是不是只要噪声值小于当前强度就消失对逻辑就是这样。消融强度从0涨到1的过程里噪声纹理上暗的地方先丢、亮的地方后丢模型就会呈现一种不规则、非均匀消失的形态。2.2 为什么必须用噪声纹理不用连续渐变很多人第一次尝试消融会直接拿模型的UV做一个从左到右的渐变然后用clip(uv.x - _DissolveAmount)来消融。结果就是模型像被一把刀“切”过去直直的一条线非常不自然。噪声纹理的存在的意义就是打破这种空间连续性。自然界里的燃烧、腐蚀、风化都是局部、片区式发生的噪声图的高低起伏模拟的就是这种局部差异。第2.1节里的Shader只用了噪波的R通道其实GB通道也可以用来做更复杂的效果。比如G通道控制边缘发光的强度、B通道控制粒子发射位置这在后期的进阶用法里很常见后面会细说。2.3 关键参数表和硬件平台的兼容性说明这个Shader有几个参数需要在Inspector里反复调节我先给一个常用的参数区间和直观解释参数常见取值范围作用_MainTex模型自身纹理决定消融过程中保留部分的颜色信息_DissolveTex用户自备的噪声图决定哪些区域先消失、哪些区域后消失_DissolveAmount0 ~ 1全局消融进度0不消融1全部消失_EdgeLength0 ~ 0.5高亮边缘的带宽数值越大边缘越宽_EdgeColor任意RGBA边缘叠加的颜色一般选亮色更醒目兼容性方面这个Shader只用了tex2D和clip在Unity 2018之后的Built-in管线、URP、HDRP里都能用但要注意一点在URP里需要把#include UnityCG.cginc换成Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl并把UnityObjectToClipPos换成TransformObjectToHClip。这是一个几乎所有Built-in Shader迁移到URP都会踩的坑后面有专门一节讲。3. 动态着色的“动”从哪来C#控制、边缘发光与渐变处理3.1 C#脚本的驱动逻辑如何让消融平滑推进Shader写好了只是静态能力真正要让物体在游戏里动态消失必须有一个C#脚本持续修改_DissolveAmount参数。这里有一个常见的初学者错误在Update里每帧直接给参数赋值结果消融过程很快但很不平滑或者中途卡顿看上去很僵硬。推荐做法是写一个”动画曲线事件回调“的结构using System.Collections; using UnityEngine; public class DissolveController : MonoBehaviour { [SerializeField] private Renderer targetRenderer; [SerializeField] private AnimationCurve dissolveCurve AnimationCurve.EaseInOut(0, 0, 1, 1); [SerializeField] private float duration 2f; [SerializeField] private bool autoPlay true; private MaterialPropertyBlock propertyBlock; private static readonly int DissolveAmountID Shader.PropertyToID(_DissolveAmount); private void Awake() { if (targetRenderer null) targetRenderer GetComponentRenderer(); propertyBlock new MaterialPropertyBlock(); if (autoPlay) StartCoroutine(PlayDissolve()); } public IEnumerator PlayDissolve() { float timer 0f; while (timer duration) { timer Time.deltaTime; float progress Mathf.Clamp01(timer / duration); float amount dissolveCurve.Evaluate(progress); SetDissolveAmount(amount); yield return null; } SetDissolveAmount(1f); OnDissolveComplete(); } public void SetDissolveAmount(float amount) { targetRenderer.GetPropertyBlock(propertyBlock); propertyBlock.SetFloat(DissolveAmountID, amount); targetRenderer.SetPropertyBlock(propertyBlock); } private void OnDissolveComplete() { // 敌方单位解散后可在这里关碰撞体、禁用AI或播放回收特效 gameObject.SetActive(false); } }这里有两个细节值得展开说第一用了MaterialPropertyBlock而不是直接改material。如果多个敌人共用同一个材质直接改material会创建材质实例且每改一次就是一次GPU资源上传遇到几十个同屏敌人时性能会明显下降。而MaterialPropertyBlock可以在不破坏合批的前提下给单个物体指定属性开销小得多。第二为什么用动画曲线而不直接线性增长。线性增长的消融过程是稳定推进的视觉上偏“机械”。AnimationCurve可以做出先慢后快、或者先快后慢的节奏感这对燃烧和腐蚀这类效果至关重要——燃烧通常是开始慢、剧烈时快、最后烬灭又慢一些。曲线用EaseInOut的默认形态基本不会出错。3.2 边缘发光与消融“过渡带”的Shader实现上面第2.1节的Shader硬剪裁之后边缘是一条尖锐的折线视觉上像“被挖掉一块”缺少材质感。为了让消融效果更接近燃烧、高温、魔法消散等需求必须在边缘处做一个渐变高亮带。其原理不复杂边缘本质上是噪声值和消融强度的差值小于某个阈值的像素集合。把“差值”算出来用它做边缘发光的亮度权重差值越小说明这个像素越靠近当前消融边界亮度就应该越高。然后把边界亮度乘以边缘颜色叠加到主颜色上fixed4 frag(v2f i) : SV_Target { float noise tex2D(_DissolveTex, i.uv).r; float diff noise - _DissolveAmount; clip(diff); // 边缘亮度计算 float edgeSmooth smoothstep(0.0, _EdgeLength, diff); float edgeGlow (1.0 - edgeSmooth) * _EdgeIntensity; fixed4 albedo tex2D(_MainTex, i.uv); fixed4 finalColor albedo _EdgeColor * edgeGlow; return finalColor; }关键是smoothstep(0.0, _EdgeLength, diff)这一行。它把 diff 值在0到_EdgeLength范围内做了平滑映射。diff为0即将被裁剪的边缘时返回0diff等于_EdgeLength时返回1中间是平滑过渡。这样1.0 - edgeSmooth在边缘处接近1、向外衰减到0实现了边缘高亮的渐变带。从美术表现上看这个边缘带像一层“烧红的热边”。调_EdgeColor为橙色系时有高温感调成青色系时有冰冻碎裂感调成紫色时有暗影魔法感。一套Shader改三个颜色值就能适配完全不同的游戏风格。3.3 多纹理混合的进阶让边缘内部也有明暗起伏如果只是叠加一个边缘发光消融边缘仍然显得“软绵绵”的因为整个边缘带宽的亮度过于均匀。想要更细腻的细节我一般会在边缘计算后额外乘一个细节噪声让发光区域产生斑驳感float detailNoise tex2D(_DetailTex, i.uv * _DetailScale).r; edgeGlow * detailNoise;这样每段边缘的亮度高低错开像真实的燃烧火焰不均匀地舔舐着物体表面消融出来的撕扯感会强烈很多。_DetailTex可以用另一张分形噪声图也可复用_DissolveTex的G通道节省一次纹理采样。4. 让效果更“活”的动态细节从平面消融到立体破碎4.1 消融方向控制模型位置、法线与局部坐标的取舍很多消融效果需求不是“从表面均匀消失”而是有方向性的。比如岩浆溶解地面是自下而上消失被黑洞吸附的敌人是从四周向中心消失角色临死前是想让能量从胸口向外扩散消失。控制方向的方式有三种各有利弊方式一基于模型高度Y轴。采样时额外传入模型的世界坐标Y值用它和噪声值相加或相乘。这种方案适合“从脚到头逐渐蒸发”的默认式特效操作最简单。float worldY i.worldPos.y; float noise tex2D(_DissolveTex, i.uv).r worldY * _DirectionFactor;但这种方式对模型原点和尺寸很敏感如果美术把模型锚点放在脚底或者头顶效果差异会很大需要_DirectionFactor配合模型高度范围调试。方式二基于模型本地坐标的球状距离场。在顶点着色器里取模型本地顶点坐标算出它到指定中心点的距离将这个距离作为噪声偏移。玩法上是“从某个点开始向外扩散消失”特别适合能量爆破、虚空吞噬类效果。方式三直接控制噪声纹理本身。让美术做一张UV朝向合理、黑白分布符合需求方向的噪声图Shader完全不用特殊逻辑只要考虑怎么做UV扰动。这种方式在动态方向变化需求不多的时候效率最高但复用性和灵活性最低。我在实际项目里用得最多的是第二种。因为它把“方向控制”从Shader里的简单数学变成了“中心点在哪、扩散多快”的逻辑层问题策划和美术可以直观地调。4.2 顶点偏移消融不是只有“裁剪”还可以让物体碎片化如果只靠clip()裁剪消融边缘是一个2D贴图意义上的边界模型本身的网格不会产生位移。对于一些设定——比如物体是被风吹散、被震碎、被能量撕开——光有裁剪不够还需要顶点层面的偏移让边缘附近的顶点发生向外的位移形成“剥离感”。在顶点着色器里加一段v2f vert(appdata v) { v2f o; float noise tex2Dlod(_DissolveTex, float4(v.uv, 0, 0)).r; float diff noise - _DissolveAmount; clip(diff); // 顶点层面也做一次裁剪提前剪切掉完全不可见的部分 float3 worldNormal UnityObjectToWorldNormal(v.normal); float offsetStrength smoothstep(0.0, _EdgeLength, diff); float shake (1.0 - offsetStrength) * _ShakeStrength; v.vertex.xyz v.normal * shake; o.vertex UnityObjectToClipPos(v.vertex); o.uv TRANSFORM_TEX(v.uv, _MainTex); return o; }这里的逻辑是顶点距离消融边界越近diff越接近0它被外推的幅度越大。在视觉上消融边界处的面片会向外“翘起”而不是彻底切断配合上面说的高亮边缘就有一种物体被高温吹散的撕裂感。有一点必须提醒顶点着色器里用tex2Dlod而不是tex2D。因为片段着色器和顶点着色器的采样要求不同顶点着色器阶段不能使用屏幕空间导数的tex2D这是Shader编译报错中很常见的一条写熟了也会偶尔犯。另外顶点偏移会对模型的物理碰撞体完全不生效。如果物体刚体还挂着消融到一半时叠一个碰撞体玩家就会撞到“看不见的墙”。这方面最好在消融开始时就把碰撞体禁用或替换成弱化碰撞体。5. 材质表现和移动端适配性能、合批与不同渲染管线的坑5.1 移动端的开销分析纹理采样次数和ALU是真正的瓶颈移动端GPU和桌面端GPU差异很大尤其是大量使用Shader在低端机上跑时主要瓶颈有两个纹理采样次数和ALU算术逻辑单元指令数。上述消融Shader默认的一次片元执行包含两次纹理采样主纹理、噪声图、一次clip分支和一些简单数学计算。在移动端每增加一次全屏的纹理采样都可能带来5%到10%的帧率下降。因此移动端优化第一法则能用一张纹理搞定就不要用两张。推荐的移动端最优结构主纹理用原模型的Albedo。噪声纹理放在同一张纹理的Alpha通道或G通道里和主纹理合图。边缘发光不再采样额外纹理而是直接用噪声的导数信息来模拟。有些美术会担心合图后过滤拉伸导致噪声不完整实际测试下来把噪声图合到2048的RGBA里压缩格式设为ASTC在绝大多数手机上都能保证足够的噪声细节。5.2 URP下降级方案与_CameraDepthTexture带来的隐患如果你在用URP管线迁移消融Shader有个容易炸的坑URP里如果开启了DepthTexture那么一些后期效果比如全屏扫光、描边可能会依赖它但在自定义Shader里如果误用了_CameraDepthTexture又没正确声明会导致移动端所有物体渲染发黑或深度数据错乱。消融Shader本身不需要深度纹理所以我的建议是不声明、不依赖。如果一定要做“被遮挡物体也显示消融边缘”这类X光效果再单独引入深度采样并锁定URP的DepthTexture选项。另外URP下还有一个坑Shader里如果用了_MainTex却忘了声明_MainTex_ST缩放偏移控制会失效。这在Built-in里可能只是纹理Tiling不生效在URP里直接会报错。写URP Shader的时候习惯性把TEXTURE2D(_MainTex); SAMPLER(sampler_MainTex);以及float4 _MainTex_ST;一起写全能少走很多弯路。5.3 两套管线共存的ShaderLab结构因为不同管线切换太痛苦我现在写的消融Shader倾向于在ShaderLab里做多Pass兼容。思路是主Pass按URP写在HLSLINCLUDE里同时定义好Built-in的宏开关用#ifdef _URP来切换核心函数。对于篇幅很小、函数简单的消融Shader这其实不算复杂度爆炸反而避免维护两套文件。HLSLINCLUDE #include Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl ENDHLSL但要注意以HLSLINCLUDE方式包含URP库后这个Shader就无法在Built-in管线里正常编译了。如果项目还没定管线或者需要同时支持两种管线可以选用包管理器里的Render Pipeline Converter或写两套SubShader。我的经验是用SubShader分离Built-in的SubShader不带TagURP的SubShader加RenderPipelineUniversalPipeline的Tag让Unity自动匹配。6. 性能优化、踩坑记录与实用排错链路6.1 常见错误为什么消融Shader有时会出现菱形块状边缘我第一次做完消融Shader后发现模型在消融过程中边缘会出现很多不自然的块状菱形就像低清马赛克一样。排查了一整天才发现问题出在噪声纹理的导入设置上。Unity导入纹理时默认会生成mipmapmipmap会在距离相机较远时使用低分辨率贴图。如果Shader采样噪声用的是没有指定mipmap级别的tex2D在远距离看这个噪声时细节丢失导致边缘呈块状分布。解决方式有两种在噪波纹理的导入设置里关掉Generate Mip Maps因为消融噪声本质上是一种程序化纹理不需要mipmap。在Shader里用tex2Dgrad或者tex2Dlod显式控制采样层级。大多数项目里第一种就够用。但要注意关闭mipmap后如果相机镜头距离拉很远噪声的远距离闪烁现象会比较明显这时反向优化反而是把mipmap打开并设置高各向异性过滤。6.2 排查案例消融边缘发亮但裁剪线卡成硬边的定位链路这里放一个我实际遇到并排查真实案例的完整链路方便大家照猫画虎。现象消融Shader烧到一半时边缘高亮颜色正确出现了但物体消失的边界依然是一条又硬又直的锯齿线。第一步排查边缘亮度计算。确认diff noise - _DissolveAmount和smoothstep(0, _EdgeLength, diff)表达式无误且_EdgeLength数值不是0。在Inspector里把_EdgeLength调到0.3边缘依旧硬边。第二步排查MaterialPropertyBlock的赋值。发现_EdgeLength确实传到了Shader不是C#赋值问题。第三步在frag里直接返回edgeGlow看一下。结果屏幕上一片黑只有几个像素亮说明边缘计算本身已经是大致可用的。第四步查看噪声纹理的Wrap Mode。发现噪声纹理的Wrap Mode设为Clamp导致在UV接近0或1时出现采样值被强制锁边噪声在那里几乎是平的产生硬切边。修正为Repeat后问题消失。这个案例的教训是同一个效果出问题从“边缘亮不亮”“该消失的地方有没有消失”“消失边界长什么样”三方面逐一排查把参数、输入纹理、函数输出分开验证。不要一上来就怀疑Shader语法很大概率是纹理导入设置拖后腿。6.3 安全性提醒Shader里慎用if分支和循环写Shader不像写C#GPU对分支和循环的支持很弱。虽然clip()本身是一种隐式丢弃不是性能杀手但如果写成if (diff 0) discard;这一句在不同GPU上可能降速严重。尤其移动端和VR设备分支会让着色器的执行模型变得很混乱。尽量都用clip()或者用lerp/smoothstep去平滑过渡能用数学表达的就不要用分支。另外如果要用for循环扫描多个噪声层循环次数必须是编译期常量且尽量设小一点。移动端循环超过4次帧率会肉眼可见地往下掉。7. 从消融到通用特效扩展出更多动态着色玩法7.1 烧毁、腐蚀、冰冻碎裂三个实际变体这套着色器只要做少量参数和逻辑方面的调整就能覆盖更多特效。燃烧消退把边缘颜色调成黄橙色加入第二层外发光并且让噪声的采样UV随着时间缓慢偏移模拟燃烧时火焰边缘的游走和热浪扭曲。float2 uvOffset float2(_Time.y * 0.02, _Time.y * 0.01); float noise tex2D(_DissolveTex, i.uv uvOffset).r;冰冻碎裂把边缘颜色改为亮蓝白色配合顶点偏移方向统一向内收缩表现物体被冻结后崩裂掉落的形态。顶点偏移方向改成-v.normal视觉上变成向内塌缩。虚空湮灭边缘颜色为暗紫色黑灰色消融强度从1降到0反向播放同时顶点不偏移纯靠边缘高亮的闪烁表现物质消失。7.2 动态着色的另一个方向多频次消融刷新有些游戏需求是同一片草地或同一堵墙面反复消融/恢复。如果每次新建一个材质实例会累积内存峰值更稳的做法是直接用MaterialPropertyBlock的变量覆盖在外面维护一个消融状态机用同一个Renderer反复刷值。这个方案在移动端反复生成物件的场景里非常有用可以大幅减少DrawCall和材质实例数量。7.3 和事件系统联动消失结束往哪里回调消融效果几乎都是跟玩法事件绑定的敌人死亡掉落、场景机关打开、道具激活。Shader本身不会发事件所以我习惯在C#脚本里加一个UnityEvent onDissolveComplete在协程执行完毕后Invoke。这样策划和开发就可以在Inspector里直接把后续动作配置上去比如从池里回收、播放音效、生成掉落物。代码风格上看着多写了一行但实际使用效率提升非常明显。8. 写在最后的实操建议消融效果看起来是个小功能但真正做完一遍后会发现它牵扯到的面很广纹理设计、Shader数学、C#驱动逻辑、渲染管线兼容、移动端性能优化外加特效表现上的审美积累。以下几个点是我做了多个项目之后沉淀下来的固定姿势供参考噪声纹理尽量做成灰度连续图不要用高对比度硬边噪点否则边缘会“扎手”边缘发光别贪宽一般控制在0.05到0.2之间太宽会遮住原有材质细节消融强度参数在材质面板上默认设为0否则物体一进场景就自己消失了。调试时先在Preview窗口里调材质参数再放到实际灯光环境下看效果——很多边缘颜色在纯白环境下好看到了暗场景就显得脏。最后建议把整套效果封装成一个预制体组件不要写成一次性脚本。一旦做成了通用组件后面换个项目换个题材都能直接拖出来用这才是做游戏开发工具该有的思路。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →