ARTICLE DETAIL

资讯详情

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

Unity消融效果Shader实现:动态着色与像素裁剪实战

Unity消融效果Shader实现:动态着色与像素裁剪实战 做技能特效和战斗表现的人最常被问到的一个需求就是怎么让一个模型在游戏里像被高温熔化一样从某个位置开始一点点碎掉、烧掉边缘还带一圈发光。这个需求在动作游戏、卡牌大招、甚至场景解密里都很常见。今天要聊的Unity消融效果就是专门干这件事的——它借助GPU着色器在渲染像素的时候直接决定哪些像素要不要画出来同时把边缘像素的亮度和颜色动态提亮形成那种烧蚀、熔断的视觉冲击。整个过程不需要手动改网格顶点也不依赖粒子去遮罩性能开销小视觉结果稳定。适合正在做技能特效、怪物受击/死亡、场景加载过渡、机关开启动画的开发者参考也适合想搞清楚着色器里“动态着色”到底怎么回事的朋友入门。很多第一次接触Shader的新手听到“消融”都会觉得是很高端的技术。其实把它拆开看核心就是三件事一张噪点图、一个进度值、一个clip裁剪。只要把这三样东西玩明白了你不仅能做消融还能顺手做出一堆类似的动态着色效果比如冰冻碎裂、空间撕裂、武器觉醒附魔。这篇文章我会从原理讲到代码再讲到实战中容易踩的坑尽量把每一步的“为什么”都说清楚。1. 消融效果的实现思路与核心概念拆解1.1 消融的本质不是“删掉模型”而是“丢弃像素”游戏里一个模型显示在屏幕上本质上是由很多三角形组成的每个三角形在光栅化阶段会被切割成一个个像素点。消融的做法是在片元着色器Fragment Shader里拿到当前像素的坐标然后去采样一张噪点图得到一个随机的灰度值。再用一个外部传入的进度浮点数去和这个随机值比较数值小于阈值的像素直接丢弃不输出到屏幕上。随着阈值一点点变化被丢弃的像素范围就会越来越大模型看起来就像被一只看不见的手沿着表面慢慢啃掉。你可以想象成一张白纸上密密麻麻撒满了黑色小点然后用一把角度刁钻的剪刀沿着点阵边缘一颗一颗修剪。你并没有改变整张纸的轮廓而是从内部把它镂空最终边缘会呈现出不规则的破碎效果。这个比喻放在Shader里就是模型顶点没有任何改变改变的是每个像素的“被保留权”。这里有一个关键选择为什么不直接写C#脚本去控制顶点或Mesh的消失一方面是因为CPU逐帧改顶点数据的开销太大一个几千面的模型还能勉强接受换成几万面就扛不住了另一方面是顶点级操作根本做不出“像素级的不规则边缘”消融的视觉重心恰恰就在那一条破碎的边界线上。所以用GPU的clip指令做像素级裁剪才是最合理的方案。1.2 动态着色着色器里“活的阈值”项目标题里提到了“动态着色”很多朋友对这个词有一点误解以为是要在Shader里做颜色渐变或者流光扫过。其实这个“动态”指的是着色器的显示状态受外部参数实时驱动画面会随着参数变化而持续改变。从技术上讲Shader本身是一段在GPU上跑的代码它的输入包括顶点数据、纹理采样结果还有外部传入的uniform变量。消融进度值_DissolveAmount就是一个uniform变量你可以把它想象成Shader世界里的一根“旋钮”。C#脚本每一帧旋动一次这个旋钮GPU就会立即重新计算所有像素的位置、可见性、边缘高亮强度。阈值从0变成1模型就从完整状态一步步被蚕食殆尽阈值反向从1变回0模型则会从碎片状态重新“长出来”这个反过来玩可以用来做复活、召唤特效。真正有意思的是因为每个像素采样到的噪点值都不一样阈值变化时屏幕上的“变化节奏”也不一样。有的像素会先从噪点图的亮部塌陷有的像素撑着到最后一刻才消失。这种非同步的动态视觉就是消融效果比单纯透明度渐变更有张力的根本原因。1.3 噪点图怎么选、怎么生成消融效果里噪点图是绝对的主角。它的作用不是给人看而是给每个像素提供一柄“决定生死的筛子”。所以它的质量直接决定消融边界好不好看。我做消融项目时对噪点图的要求一般有以下几条灰度图即可不需要彩色信息Shader里采样red通道就够了。分辨率不用太高128x128或者256x256完全够用太高反而浪费采样带宽。要有足够的随机感但也不能完全白噪。纯白噪会让边缘碎得像电视机雪花太假完全平滑的渐变又会让消融像水位线整齐下降缺乏破碎感。最好是无缝平铺tileable。如果噪点图有接缝消融进行到UV边界时会出现一道非常刺眼的直线直接穿帮。噪点图的获取渠道很丰富。最省事的是在Asset Store里搜一些免费的noise texture不要花钱就能拿到。想自己生成的话可以打开Photoshop建立一张灰度图用“滤镜→渲染→云彩”生成基础噪点再叠加一个高斯模糊微调平滑度。也可以用Substance Designer里的Perlin Noise节点调好频率和随机种子输出即可。要是图省事项目里有地形或特效用的噪声贴图直接拿来用也完全没有问题。2. 动手之前的准备Shader结构和关键参数2.1 Unity Shader最小骨架解读写消融Shader之前必须先熟悉一下Unity Shader的基本骨架。一个Shader文件从上到下至少包含Shader代码块、Properties材质属性块、SubShader渲染子着色器、Pass渲染通道。GPU执行时会在SubShader里选择一个能跑到起来的Pass然后一次Pass就是一次完整的渲染流程。消融Shader通常用一个Pass就够了因为不需要多通道合成。Tags标签里有两处需要留意Queue控制渲染队列普通不透明物体写成Queue Geometry即可RenderType通常配合OpaqueUnity在做阴影、深度烘焙时会根据这个标签做分类。还有一个非常关键的命令是Cull Off也就是关闭面剔除。消融过程中模型外表面被撕开后内部会暴露出来如果开着默认的Cull Back从某些角度看过去你会发现模型里面空空如也甚至整块模型消失。改成Cull Off之后正面和背面都会被渲染消融后的内部也能被看到视觉上会饱满很多。2.2 核心属性设计与联动逻辑在Properties块里我一般会暴露五个参数属性名类型用途_MainTex2D模型的主纹理贴本体进度的颜色_NoiseTex2D噪点图决定消融边缘的形状_DissolveAmountRange(0,1)消融进度0为完整状态1为完全消失_EdgeWidthRange(0,0.5)边缘发光带的宽度_EdgeColorColor边缘发光的颜色把进度值和边缘参数都暴露出来的好处是美术同学可以在材质面板上直接拖动调试不必改动C#代码。你要是有心思还可以加一个_EdgeIntensity来单独控制发光的亮度方便做HDR和Bloom适配。这些参数之间的联动关系是_DissolveAmount决定哪些像素被丢弃_EdgeWidth决定被保留像素的“危险地带”范围_EdgeColor负责给危险地带叠一层高亮颜色。三者只要配合得当就能做出烧蚀熔断的完整视觉链。3. 完整实现从Shader到脚本的一行行实操3.1 完整Unlit基础版Shader代码直接给一套我能直接在项目里用的基础版本。为了降低理解成本我先写Unlit版本不带光照专注把消融逻辑跑通Shader Custom/Dissolve/Unlit { Properties { _MainTex (Albedo Texture, 2D) white {} _NoiseTex (Noise Texture, 2D) white {} _DissolveAmount (Dissolve Amount, Range(0, 1)) 0 _EdgeWidth (Edge Width, Range(0, 0.5)) 0.1 _EdgeColor (Edge Color, Color) (1, 0.6, 0, 1) } SubShader { Tags { Queue Geometry RenderType Opaque } Cull Off Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc sampler2D _MainTex; float4 _MainTex_ST; sampler2D _NoiseTex; float _DissolveAmount; float _EdgeWidth; fixed4 _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(_NoiseTex, i.uv).r; float threshold _DissolveAmount; clip(noise - threshold); // 计算边缘发光系数离裁剪阈值越近越亮 float edgeFactor 1.0 - smoothstep(0.0, _EdgeWidth, noise - threshold); fixed4 albedo tex2D(_MainTex, i.uv); albedo.rgb _EdgeColor.rgb * edgeFactor; return albedo; } ENDCG } } Fallback Diffuse }代码不长但里面有几个点值得好好理解。clip(noise - threshold)是整个效果的命门。clip函数接收一个数值如果这个值小于0就立刻丢弃当前像素。当noise小于threshold时noise - threshold小于0像素被丢弃形成一个洞。随着阈值不断变高高于阈值的像素越来越少消融的进度就体现出来了。边缘发光用了一个很经典的技巧先计算noise - threshold的差值再用smoothstep把它映射到0到1之间。noise恰好等于阈值时差值为0smoothstep返回0所以edgeFactor是1此时的像素位于边缘最前沿被赋予最亮的高光。随着差值增大到_EdgeWidthsmoothstep趋向1edgeFactor趋向0发光渐渐消失。这个“靠近裁剪线的地方最亮、离裁剪线越远越暗”的渐变正是消融边缘看起来有金属熔断感的原因。3.2 C#脚本控制与多物体适配Shader写好后还需要一个C#脚本来驱动进度值。最基础的做法是这样的using UnityEngine; public class DissolveDemo : MonoBehaviour { [Range(0f, 1f)] public float progress 0f; private Material _mat; private static readonly int DissolveAmount Shader.PropertyToID(_DissolveAmount); private void Awake() { _mat GetComponentMeshRenderer().material; } private void Update() { if (_mat ! null) { _mat.SetFloat(DissolveAmount, progress); } } private void OnDestroy() { if (_mat ! null) { Destroy(_mat); } } }这里有一个非常容易踩的坑获取材质时用的是renderer.material而不是renderer.sharedMaterial。两者区别在于sharedMaterial返回的是所有同网格对象共用的那份材质实例你用SetFloat改一个值场景里其他使用同一材质的对象全部跟着变而material会帮你在内存里实例化一份独立材质副本改起来互相不干扰。代价是会有一点内存开销所以记得在OnDestroy里把它Destroy掉避免泄漏。如果你想让消融动画在怪物死亡的时候播一次而不是每帧手动推进度那就更适合用协程。我给一个平时项目里常用的协程版本using System.Collections; using UnityEngine; public class DissolveAnimation : MonoBehaviour { [SerializeField] private float duration 1.5f; private Material _mat; private void Awake() { _mat GetComponentMeshRenderer().material; } public void PlayDissolve(float from 0f, float to 1f) { StartCoroutine(DissolveRoutine(from, to)); } private IEnumerator DissolveRoutine(float from, float to) { float elapsed 0f; while (elapsed duration) { elapsed Time.deltaTime; float t Mathf.Clamp01(elapsed / duration); float value Mathf.Lerp(from, to, t); _mat.SetFloat(_DissolveAmount, value); yield return null; } } private void OnDestroy() { if (_mat ! null) Destroy(_mat); } }如果场景里同时有几十个敌人需要一起消融不要每个物体都单独跑协程改成每帧遍历所有对象调用SetFloat。更合理的做法是用一个中心管理器统一管理进度或者用MaterialPropertyBlock来做批处理优化。不过基础需求用上面的协程就足够了。3.3 扩展玩法底部烧毁、中心扩散与模型空间消融上面的Shader是用UV坐标采样噪点图所以消融形状是完全随机的从模型的各个方向同时消失。但很多实战场景需要的是有方向感的消融比如地表陷落、建筑从底部向上熔化、人物从脚底开始灰飞烟灭。这时候可以把采样坐标从UV换成模型空间坐标。原理很简单每个顶点都有自己的模型空间位置float3 localPos i.objPos取值通常是-0.5, -0.5, -0.5到0.5, 0.5, 0.5这个范围。把模型空间Y轴高度合并进阈值比较就能做出从底部向上消失的效果。代码片段如下// v2f里需要加一行float3 objPos : TEXCOORD1; // 顶点阶段传递o.objPos v.vertex.xyz; float3 localPos i.objPos; float noise tex2D(_NoiseTex, i.uv).r; float mixValue noise localPos.y * 0.5f; // 调整系数控制方向感 clip(mixValue - threshold);把localPos.y乘一个系数加进噪点值里等于给噪点图叠了一个高度偏移。threshold增大时低处的像素会先触达裁剪线高处像素后触达消融呈现从下往上蔓延的趋势。想要中心向四周扩散的话把localPos.y换成径向距离length(localPos.xz)即可。这个扩展思路基本就是“噪点K值”组合拳K值可以是任意业务维度数据。4. 常见问题与排查技巧实录4.1 高频问题速查材质紫红色、模型消失、边缘锯齿现象可能原因排查与解决材质显示为紫红色Shader编译失败检查Console窗口报错重点看Properties属性名是否和Pass内变量名一致检查有没有漏了#include模型整个不见了把Cull设成了Back且消融到中间阶段视角看向背面改成Cull Off让内部面也能渲染消融边缘是整齐的横线/方块噪点图太平滑或分辨率过低换一张随机感更强的带噪声纹理或者提高分辨率边缘锯齿特别严重噪点图对比度过高对噪点图做一层轻微高斯模糊减少高频细节多个物体同时消融不同步用了sharedMaterial改成renderer.material实例化材质边缘发光闪烁_EdgeWidth设太窄像素走样把宽度调到0.05以上或者配合抗锯齿处理这里重点说一下材质紫红色。这是Unity新手最崩溃的报错之一遇到它先别慌Console窗口会提示具体错误信息。最常见的原因是Properties里定义了_MyTexturePass里却写成sampler2D _MyTextue少打一个字母编译直接失败。其次是没有正确包含Unity的内置函数库比如在CGPROGRAM里使用了UnityObjectToClipPos却忘了#include UnityCG.cginc。这些错误信息都比较明显照着编译日志一行行改就行。4.2 性能优化与真实项目里的取舍消融Shader的性能压力并不在于Shader本身的复杂度而在于clip指令破坏了GPU的一些优化路径。正常情况下图形渲染管线的早期深度测试可以在像素层面前提前丢掉被遮挡的像素但用了clip之后GPU必须等待片元着色器执行完毕才能判断这个像素是否真的有效也就是所谓的“过度绘制”。模型面数越高、屏幕占比越大这个开销就越明显。针对移动端的优化我一般做三件事降低噪点图分辨率128x128已经足够很多人直接放一张1024的噪点贴图进去白白增加带宽压力。限制消融范围不要让全屏都跑这层Shader控制在需要变化的物体上。配合粒子特效时控制好overdraw消融过程中如果同时又叠加了烟雾粒子和火花粒子移动端老一点的GPU会喘不过气。粒子系统用完一定要及时回收避免粒子数量越积越多最终变成整个场景的性能瓶颈。顺便提一句如果你在做的是战斗技能演示消融Shader尽量和透明特效分开渲染队列。给消融物体使用Queue Geometry给粒子特效使用Transparent能避免不少半透明物体排序导致的显示错乱。对于噪点图的使用还有一个小细节如果项目需求是消融到一半要能“暂停”比如BOSS倒地后保持半融化状态作为场景装饰那就要确保噪点图采样使用的是世界空间或物体本地空间的稳定UV。如果用屏幕空间UV一旦相机移动消融边缘就会像波纹一样跟着晃动非常出戏。最后分享一个我做这类效果积累下来的习惯消融进度不要从0匀速涨到1那条直线放到真实游戏里非常出戏。我一般会把进度写成一个动画曲线前30%快速撕开中间60%略有停顿最后10%突然收尾。同一个着色器配不同曲线手感差别极大。你把这个思路拿去试效果比调半天边缘颜色参数来得实在。
返回列表