尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Unity消融效果详解:Shader裁剪、边缘发光与阴影同步

Unity消融效果详解:Shader裁剪、边缘发光与阴影同步 在Unity里做角色死亡特效的时候我第一个想到的方案是让模型慢慢变透明再消失。结果做完一看整个效果像泡在水里化开的纸片没有任何冲击力。后来我把方案换成了消融Dissolve效果——物体从某一侧开始像被火焰、腐蚀或者能量吞噬一样边缘带着发光和碎裂感快速蒸发掉。这其实就是动态着色的一种典型玩法在运行时通过材质参数连续驱动Shader变化而不是简单播放一段动画。这篇文章我把自己在Unity中实现消融效果的完整思路、Shader代码、C#控制逻辑和踩过的坑都整理出来希望对做角色死亡、场景破坏、出场动画和技能特效的同学有帮助。1. 消融效果的本质不是淡出而是被切掉1.1 先从需求场景说起什么情况下你需要消融很多刚接触这个功能的人会以为消融就是透明度从1慢慢变到0。如果你做的只是一个UI面板淡出那确实用透明度就够了。但放在3D场景里用透明度做淡出会出现一堆麻烦半透明物体没有深度写入渲染顺序会乱模型前后穿插的地方会透出奇怪的重叠角色死亡时还能看到“纸片人”一样的扁平感。消融完全绕开了这些问题因为它的原理不是“变透明”而是“被切掉”——逐像素地把物体表面一块块裁剪掉直到整个模型消失。我最初是在做怪物死亡效果时用上消融的。怪物是逼真的3D模型直接淡出会显得很廉价而消融之后那种“从脚底往上烧掉”的感觉配合粒子飞散和边缘发光打击感和氛围感完全不一样。后来做场景中的大门崩塌、Boss出场、机关激活这类效果我也都用同一套思路只是换了一套颜色和速度参数。所以你如果也遇到下面这些需求可以直接把这套方案拿过去用角色或怪物死亡后的消失表现场景物件生成、坍塌、被技能破坏的过渡关卡切换时旧场景内容被“抹除”的演出技能特效比如暗影侵蚀、冰冻蔓延、圣光净化玩家视野中的遮挡物被“解除”的时刻这几个场景的核心诉求是一致的一个对象在不透明度不变的前提下从完整到消失的过程要有视觉冲击力而且中途不能产生渲染排序问题。消融就是为此设计的。1.2 核心原理拆解裁剪指令与噪点纹理消融效果的底层其实就两个关键点一个叫clip()在Unity Shader里也可以写成discard一个叫噪声纹理。先说裁剪指令。在片元着色器Fragment Shader里GPU会为每个像素执行一次着色逻辑。clip(x)的意思是如果x小于0就丢弃当前这个像素不写入颜色缓冲和深度缓冲。换句话说被“剪掉”的地方在渲染结果上就是空的。你可以把clip想象成一把裁纸刀它在片元着色器里切掉了所有不满足条件的像素。但问题来了如果我只靠一个统一的阈值去裁整个模型会在同一个瞬间一半消失一半存在中间是一条直线切面非常生硬。解决办法是引入一张噪声纹理。我们把每个像素的噪声值采样出来当作“像素的随机耐心值”再和全局的消融进度去比较。当进度缓慢从0涨到1时噪声值小的像素会先被裁剪噪声值大的像素后被裁剪。由于噪声图每个位置的值都不一样整个表面的裁剪顺序就是混乱且不规则的视觉上就出现了“被腐蚀、被吞噬”的效果。这里有一个重要的概念消融进度本身不能直接用做裁剪阈值你需要的是“噪声值”和“进度值”作比较。我的早期版本犯过一个错误以为直接让透明度线性下降就能做出消融结果模型只是均匀地变淡毫无层次感。正确地做法是建立一个映射关系当_DissolveAmount为0时所有噪声值都大于0因此没有任何像素被裁剪模型完整。当_DissolveAmount逐渐增大噪声值低于当前进度的像素一个个被丢弃模型从局部开始“缺块”。当_DissolveAmount为1时几乎所有噪声值都小于1模型完全消失除非你的噪声图里有像素值刚好等于1的极端情况。1.3 为什么用噪点图而不是普通渐变图可能有人会问我拿一张从黑到白的渐变图当作噪声源行不行行是行但效果必定很无聊。渐变图从左到右值均匀变化消融也会从左到右均匀推进切面是一条直线哪怕你加了模糊看起来也像一个矩形在缩水没有“破碎感”。噪声图的价值在于它提供了无序的空间频率有些地方连续的亮、有些地方连续的暗还有一些细碎的中间值。这样消融边缘就形成了不规则的锯齿、岛屿状残留和自然过渡。实践当中我推荐使用Perlin噪声图或者FBM分形布朗运动叠加后的灰度图而不是纯白噪点图。纯白噪点的高频信息太多消融边缘会变得像雪花屏幕一样细碎缺少“块状感”。FBM或者普通Perlin噪声图则有合适的空间尺度消融时既能看出整体轮廓的缩减又保留了局部的细节。你可以在Photoshop里用云彩滤镜生成也可以从Asset Store下载现成的Noise Texture包网上有很多免费的灰度Noise贴图单个通道就够用。关键是不要用带Alpha通道的彩色纹理当噪声图直接采样的G或R通道会包含奇怪的颜色信息干扰结果。还有一个容易被忽略的点噪声贴图的Tiling设置会影响消融的碎片大小。如果Tiling太大消融边缘会非常细碎像掉渣Tiling太小模型可能大块大块地消失缺少渐变感。我自己的经验是对角色模型UV平铺1到2比较自然对墙体、巨石这类大体量物件Tiling可以放到3到4制造更粗糙的剥落感。这个参数没有绝对标准你需要在材质面板上反复试找到符合表现调性的尺度。2. 完整Shader实现从基础版到带边缘光2.1 一个可以跑起来的基础版Shader下面先给出一套可以直接用的Built-in渲染管线Shader。不管你是Unity 2019还是2021只要不是URP工程新建一个Standard Surface Shader然后替换内容即可。我习惯用顶点片元着色器而不是Surface Shader因为消融需要精细控制裁剪时机而且将来移植到URP也更方便。Shader Custom/DissolveEffect { Properties { _MainTex (主纹理, 2D) white {} _NoiseTex (消融噪点图, 2D) white {} _DissolveAmount (消融进度, Range(0,1)) 0 _EdgeColor (边缘颜色, Color) (1, 0.6, 0, 1) _EdgeWidth (边缘宽度, Range(0, 0.2)) 0.05 } SubShader { Tags { RenderTypeOpaque QueueGeometry } LOD 200 Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float2 uv : TEXCOORD0; float4 vertex : SV_POSITION; }; sampler2D _MainTex; float4 _MainTex_ST; sampler2D _NoiseTex; float _DissolveAmount; fixed4 _EdgeColor; float _EdgeWidth; 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 { // 基础颜色 fixed4 col tex2D(_MainTex, i.uv); // 采样噪声图R通道 float noise tex2D(_NoiseTex, i.uv).r; // 核心裁剪噪声值小于消融进度时丢弃像素 clip(noise - _DissolveAmount); return col; } ENDCG } } FallBack Diffuse }这个版本已经能实现最基础的消融拖拽材质面板上的_DissolveAmount从0到1模型会不规则地被裁剪直到消失。注意FallBack Diffuse很重要否则在场景里被阴影照射时可能会出现错误。2.2 给消融边缘加上发光感基础版本只能看到“缺了”但没有“燃烧”或者“能量腐蚀”的质感。要让消融有视觉冲击力边缘发光是标配。原理是在噪声值和进度值接近的那一圈像素上把输出颜色替换为边缘光颜色再和原始纹理做一个混合。这样在消融边界处会有一圈高亮模拟烧红的金属边缘或者能量残余。修改片段着色器如下fixed4 frag (v2f i) : SV_Target { fixed4 col tex2D(_MainTex, i.uv); float noise tex2D(_NoiseTex, i.uv).r; // 先裁剪 clip(noise - _DissolveAmount); // 计算边缘因子在阈值附近为1越远离阈值越小 float edgeFactor smoothstep(_DissolveAmount, _DissolveAmount _EdgeWidth, noise); edgeFactor 1.0 - saturate(edgeFactor / max(_EdgeWidth, 0.001)); // 边缘颜色混合 fixed4 finalColor lerp(col, _EdgeColor, edgeFactor * 0.8); finalColor.rgb _EdgeColor.rgb * edgeFactor * 0.5; return finalColor; }这里smoothstep的作用是把“距离阈值远近”映射到0到1的连续值。当noise刚好等于_DissolveAmount时edgeFactor接近1显示强边缘当noise远大于阈值也就是该像素离消融边界很远时edgeFactor接近0保持原纹理。edgeFactor最后乘以0.8是为了保留一点纹理细节不要把整个像素完全变成纯色否则消融边缘会显得很“塑料”。后面一行finalColor.rgb _EdgeColor.rgb * edgeFactor * 0.5是模拟自发光Emissive让边缘颜色变亮而不是变暗这样在暗色场景里会非常出效果。如果你用的是HDR环境还可以把这里的自发光强度提到1以上再加上Bloom后处理那效果会非常惊艳——边缘会像烧红的炭火一样带光晕。这一点我在第四节末尾还会提。2.3 Shader参数暴露美术和策划怎么调Shader写出来不是给自己看的最终要交给美术、策划去调。所以参数命名一定要直观而且范围要合理。我习惯把每个参数都用下划线开头保持Unity原生属性命名的规范同时面板显示名写清楚是给中文团队看还是英文团队看。属性名面板名称范围作用说明_MainTex主纹理默认贴图物体本身的颜色纹理_NoiseTex消融噪点图灰度图决定消融边缘形状的关键贴图_DissolveAmount消融进度0到10为完整1为完全消失_EdgeColor边缘颜色颜色拾取器消融边界的发光颜色_EdgeWidth边缘宽度0到0.2边界高亮带的宽度数值越大越宽这里有一个值得注意的点_DissolveAmount尽量不要在Shader内部做反转就统一按照“0为完整、1为消失”来设计。因为C#侧控制的时候正向思维最不容易出错。如果你做的是“从无到有”的生成效果那只需要让进度从1降到0不需要改Shader逻辑。3. C#脚本驱动从“静态效果”到“动态过程”3.1 用协程控制消融的完整流程Shader只是提供了“能力”真正让消融动起来的是C#侧不断改变_DissolveAmount的值。最朴素的写法是写一个MonoBehaviour在Update里累加时间和进度。但如果你的项目里有大量需要消融的对象我更推荐用协程或者可取消的异步逻辑这样方便控制速度、曲线和结束回调。这是一个可以直接用的控制器脚本using System.Collections; using UnityEngine; public class DissolveController : MonoBehaviour { [Header(目标渲染器留空自动获取自身)] [SerializeField] private Renderer[] targetRenderers; [Header(消融时长)] [SerializeField] private float duration 1.5f; [Header(消融曲线)] [SerializeField] private AnimationCurve curve AnimationCurve.EaseInOut(0f, 0f, 1f, 1f); private int dissolveAmountID; private void Awake() { if (targetRenderers null || targetRenderers.Length 0) { targetRenderers GetComponentsInChildrenRenderer(); } dissolveAmountID Shader.PropertyToID(_DissolveAmount); } public void PlayDissolve() { StartCoroutine(DissolveRoutine()); } private IEnumerator DissolveRoutine() { float elapsed 0f; while (elapsed duration) { elapsed Time.deltaTime; float t curve.Evaluate(elapsed / duration); foreach (var r in targetRenderers) { r.material.SetFloat(dissolveAmountID, t); } yield return null; } // 消融完成后关闭渲染避免空模型继续占资源 SetRenderersActive(false); } private void SetRenderersActive(bool active) { foreach (var r in targetRenderers) { if (r ! null) { r.enabled active; } } } }这段代码里有几个细节值得关注。第一Shader.PropertyToID(_DissolveAmount)避免了每帧字符串传参的GC开销项目里如果同时有几十个物体在消融这个优化是实打实的。第二用AnimationCurve让美术能够调整消融是“先慢后快”还是“先快后慢”这是表现质感的关键。默认的EaseInOut用于大多数情况没问题但如果你做的是爆炸消散用“先快后慢”会更自然。第三消融完成后一定要把Renderer禁用。否则即使Shader裁剪掉了所有像素物体依然会被渲染管线当作可见物处理白白消耗一次Draw Call而且在某些平台上还可能引发边界问题。3.2 避免材质实例爆炸MaterialPropertyBlock的正确用法上面那段代码有个隐藏问题r.material.SetFloat()会为每个Renderer生成一个材质实例。如果场景里只有一两个角色消融内存压力不大。但敌人一波刷出来几十个每个角色身上3个Material瞬间就多出上百个材质实例不仅占内存还会打断动态合批和SRP Batcher。我早期项目就是这样消融功能上线后Profiler里材质数量直线飙升Draw Call也翻了一倍。更稳的写法是使用MaterialPropertyBlock简称MPB。MPB允许你在不生成新材质实例的情况下覆盖Shader属性多个Renderer只要共享同一个材质就可以各自设置不同的_DissolveAmount。改造后的代码非常简单private void SetDissolveProgress(Renderer r, float t) { var mpb new MaterialPropertyBlock(); r.GetPropertyBlock(mpb); mpb.SetFloat(dissolveAmountID, t); r.SetPropertyBlock(mpb); }不过在动态场景里每帧都新建MaterialPropertyBlock也会有GC压力更好的做法是预分配一个MPB实例并复用它。由于MPB本质上是属性的覆盖集合多个Renderer之间共享同一个MPB可能会导致互相覆盖所以你在循环里调用GetPropertyBlock(mpb)把它当前的值拉出来再修改你要设置的字段最后SetPropertyBlock回去这样就不会丢失其他系统写入的属性。如果你只控制_DissolveAmount这一个属性并且不和其他系统共用该Renderer也可以直接把预分配的MPB反复使用。我实际项目中一般会把Renderer的原始MPB缓存一份需要时修改完再塞回去。3.3 消融进度与阴影同步怎么避免“阴魂不散”这里有一个几乎所有初做消融的人都会踩的坑模型已经烧没了但它的阴影还稳稳地投在地面上看起来像有一块看不见的黑色石头挡住了光。原因是Unity的阴影投射ShadowCaster Pass默认走的是另一个Pass和我们在上面写的Pass不一样。如果你只裁剪了主Pass的像素ShadowCaster Pass并不知道要裁剪阴影自然不会被消掉。最简单的解决方法是直接关闭物体投射阴影Cast Shadows设为Off但这样消融过程中地面会突然失去阴影也很奇怪。正确做法是在Shader里额外写一个ShadowCaster Pass并在这个Pass里做同样的噪点裁剪逻辑Pass { Name ShadowCaster Tags { LightModeShadowCaster } CGPROGRAM #pragma vertex vertShadow #pragma fragment fragShadow #pragma multi_compile_shadowcaster #include UnityCG.cginc struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { V2F_SHADOW_CASTER; float2 uv : TEXCOORD1; }; sampler2D _NoiseTex; float _DissolveAmount; v2f vertShadow (appdata v) { v2f o; o.uv v.uv; TRANSFER_SHADOW_CASTER_NORMALOFFSET(o) return o; } fixed4 fragShadow (v2f i) : SV_Target { float noise tex2D(_NoiseTex, i.uv).r; clip(noise - _DissolveAmount); SHADOW_CASTER_FRAGMENT(i) } ENDCG }这个ShadowCaster Pass的核心就一行重点clip(noise - _DissolveAmount)。它保证了投影时也会做完全一致的裁剪。这样消融到一半时阴影也只剩下一半视觉上非常连贯。4. 实战中绕不开的坑每个Unity开发者都会踩4.1 阴影深度测试物体消失后阴影残留我在3.3给出ShadowCaster Pass之后有同行问过我为什么写了这个Pass阴影还是有残影排查后发现Shader里写ShadowCaster Pass时Tags { LightModeShadowCaster }必须放在Pass级别不能放在SubShader级别。另外要注意的是如果你的角色是SkinnedMeshRenderer模型的网格在动画驱动下会有蒙皮变形ShadowCaster Pass里的TRANSFER_SHADOW_CASTER_NORMALOFFSET宏要正确处理法线偏移否则阴影边缘会出现一圈黑边或者Z-fighting。可以尝试把#pragma multi_compile_shadowcaster换成#pragma multi_compile _ SHADOWS_SOFT或者直接去掉法线偏移宏根据项目实际验证。另一个容易误判的情况是很多角色允许投射阴影但地面用的是实时阴影半透明覆盖材质这时候阴影残影可能根本不是你Shader的问题而是地面Shader的接收方式出现了深度重叠。你可以用Unity Frame Debugger查看ShadowMap的生成结果如果ShadowMap里那段区域本身是空的就说明ShadowCaster Pass正常如果ShadowMap里有模型但主相机看不到再去检查ShadowCaster Pass。我有一个经验先关掉阴影确认消融效果本身OK再打开阴影排查这样能把问题隔离出来不会两个问题的原因混在一起。4.2 多材质对象与SkinnedMeshRenderer的坑一个角色身上常常有多个材质身体一个、衣服一个、武器一个。如果消融想让全身同时消失就必须遍历所有材质并分别设置参数。但这里有个容易踩的坑如果你只给Renderer的主材质renderer.material设置了属性其他子材质不会改变视觉效果就是身体消失了衣服还浮在空中。所以消融控制器里一定要处理所有子材质。使用MPB的情况下GetPropertyBlock(mpb)拿到的其实是Renderer共享的属性覆盖对所有的submesh是统一的。这反而是好事不用逐个材质处理。如果你还是用renderer.material的写法那么请这样设置Material[] mats renderer.materials; for (int i 0; i mats.Length; i) { mats[i].SetFloat(dissolveAmountID, t); } renderer.materials mats;注意renderer.materials每次访问都会分配新数组帧循环里频繁调要小心GC。SkinnedMeshRenderer还有一个单独的坑它的sharedMesh可能在运行时被骨骼动画修改如果你在Awake里缓存了GetComponentsInChildrenRenderer()消融过程中新增的Renderer不会自动被覆盖。如果角色有装备槽换装系统建议在装备变更时重新获取Renderer列表。4.3 不同UV坐标系下的消融方向异常很多美术同学做角色的时候UV是拆得很碎的或者直接用了第二套UVUV2来存储光照贴图。如果你在Shader里用主纹理的UV去采样噪声图在UV接缝处会出现明显的消融不连续好像模型被“切错位”了。这时候有两个选择使用三平面映射Triplanar Mapping采样噪声图根据法线方向选择XY/YZ/ZX平面保证模型表面噪声连续。使用世界空间坐标采样噪声图直接忽略UV差异所有物体统一噪声尺度。方案2最省事但对于大世界里远处和近处的物体噪声尺度会不一样需要手动调整Tiling。方案1效果好但代码量多一点适合角色这类需要精细表现的对象。我实际项目里通常用世界坐标的Y轴叠加一个变化量作为噪声采样的偏移这样消融边缘会呈现从下往上逐渐吞噬的动势比UV采样的随机性更有方向感。做法是在顶点着色器里把worldPos.y传递到片元片元里采噪点时对UV做一次偏移。4.4 移动端与URP管线兼容性如果你用的是Unity 2019.3以上且开启了URP上面这套Shader不能直接生效因为URP默认使用SRP Batcher内置管线的UnityCG.cginc又不完全兼容而且Pass的LightMode需要改成UniversalForward或SRPDefaultUnlit。最简单的方式是用Shader Graph来做消融创建Unlit Shader Graph添加Noise节点、Step节点和Clip节点几分钟就能搭出一个消融效果。不过我个人仍然偏好手写HLSL因为对微调更可控性能也更透明。手写URP版Shader时核心改动是把CGPROGRAM换成HLSLPROGRAM包含Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl然后用GetVertexPositionInputs获取顶点信息。裁剪逻辑本身完全一致依然是clip(noise - _DissolveAmount)。另外注意URP下如果要继续投射阴影阴影Pass的顶点函数需要使用ShadowVertex或者URP专用的ShadowCasterPass代码不能照搬Built-in的宏。我在URP项目里实测过同样的噪声图和进度参数最终的画面表现和Built-in没有视觉差异所以只需要关心Shader改写成本。移动端方面主要关注点在于clip本身在移动GPU上没问题但smoothstep的指令数略高如果物体量大尽量减少边缘光计算。Adreno和Mali系列GPU对动态分支和纹理采样都比较敏感消融效果里至少有两个纹理采样主纹理和噪声图对于低端机已经是两倍的采样压力。建议把噪声图的mipmap打开并且在不需要精细表现时把Tiling调小降低采样压力。如果帧率还是不够可以在C#侧减少同时消融的对象数量配合对象池做排队播放。4.5 消融完成后的对象生命周期管理最后一个非常容易被忽略的点消融结束后除了禁用Renderer你还需要决定这个物体是销毁、入池还是隐藏。如果直接Destroy场景里可能还挂着其他脚本引用它会报MissingReferenceException。如果入池记得在对象池激活时将_DissolveAmount重置为0并把Renderer重新启用否则下一次激活还是会保持上次的消失状态。我在一个多人实战斗争项目里遇到过这种问题怪物死亡后播放消融效果完成就Destroy。但同一时间有多个怪物死亡池化的对象复用之后新怪物出生瞬间就消失了。排查半天发现是对象池复用时忘了重置_DissolveAmount。这个问题用代码看很简单但在真机上出现的时机很“灵异”因为不是必现只有上一次消融进度刚好卡在1的废旧对象复用时才触发。后来我干脆写了一个ResetDissolve()方法在对象池OnEnable里强制把材质参数设为0彻底绝了后患。还有一点关于整体包围盒剔除。Unity默认会做视锥体剔除如果模型已经消融到只剩零碎小块模型包围盒还是原始大小它不会被剔除会继续参与渲染。如果你希望消融过程结束后真的减小开销可以在消融过程中定期用Renderer.bounds做一次视锥检测或者在结束点手动禁用。对于特别大的场景物件比如一堵墙消融到一半时包围盒仍然巨大不能靠视锥剔除降载这时至少要做到禁用Renderer。收尾的一些个人实践体会上面这套方案在我实际项目里经过了三轮迭代才稳定下来。第一版只做了基础裁剪结果美术说“就像被狗啃了没有灵魂”第二版加了边缘光整个质感立刻上来了第三版补齐了阴影Pass和MPB才真正敢放到战斗场景中。这里再分享两个很小的技巧。第一个是用第二张渐变纹理控制边缘颜色的变化也就是从暗红色到亮橙色再到白热色这样消融边缘会有“温度梯度”的层次感比单一颜色丰富很多。实现时可以用_EdgeColor做主色再用_EdgeColorHot覆盖靠近切割线最近的那一圈配合smoothstep的结果做lerp。第二个是如果你做“从无到有”的生成效果不一定要改Shader逻辑只需要把C#侧循环改成从1降到0再做一次反转曲线即可效果同样自然。消融这个效果技术门槛其实不高但真正决定它好看与否的往往在细节噪声图选没选对、边缘宽度合不合适、阴影同步没同步、进度曲线手感和战斗节奏搭不搭。希望这篇文章能帮你在Unity里少走点弯路。如果后面有条件我还会把URP中的完整Shader和配合Timeline做演出编排的方案整理出来。
返回列表