公司动态

Unity ShaderLab实现沿Y轴溶解效果:从原理到实战应用

📅 2026/8/14 4:02:44
Unity ShaderLab实现沿Y轴溶解效果:从原理到实战应用
1. 项目概述为什么我们需要一个沿Y轴的溶解效果在Unity开发中尤其是涉及角色死亡、物体消失、场景转换或者一些酷炫的技能特效时一个平滑、可控的“溶解”效果是提升视觉表现力的利器。你肯定见过那种物体从下到上或从上到下逐渐化为灰烬或光粒消散的效果它比简单的淡出Fade Out更具动态感和叙事性。今天要聊的就是如何用ShaderLab亲手实现一个沿Y轴方向的溶解效果。为什么强调“沿Y轴”因为溶解效果的核心在于一个“消融”的边界这个边界的移动方向决定了视觉效果是“沉入地底”还是“升入天空”。市面上很多教程和资源提供的溶解Shader其消融方向往往是基于噪声纹理的UV或者世界坐标的某个分量不够直观和线性。而一个纯粹的、可自定义起始点和速度的Y轴溶解能让我们更精确地控制物体“消失”的动画比如让一个战败的机器人从脚部开始熔化或者让一个宝箱从顶部开始开启并消散。这对于游戏逻辑触发、过场动画的节奏把控至关重要。这个效果看似复杂但拆解开来核心就是片段着色器Fragment Shader中的一次“裁剪”clip操作。我们将物体的世界空间Y坐标或模型空间Y坐标与一个动态变化的“溶解高度阈值”进行比较低于这个阈值的像素直接被丢弃不渲染高于的则正常或进行边缘发光等处理。通过动画系统或脚本控制这个阈值的变化就能驱动溶解过程的进行。接下来我会带你从零开始一步步构建这个Shader并深入每个参数和步骤背后的原理让你不仅能“抄作业”更能真正理解并灵活运用它。2. 核心原理与Shader结构设计2.1 溶解效果的图形学本质从渲染管线的角度看溶解效果是一种逐像素的丢弃操作。在片段着色器阶段GPU会为每个需要渲染的像素更准确说是片元执行一次我们的Shader代码。我们在这个阶段计算出一个值用来判断“这个像素是否应该被画出来”。最直接的方法是使用HLSL中的clip(x)函数。这个函数接受一个浮点数参数x。如果x小于0那么当前像素的整个处理流程将被立即终止不会写入颜色缓冲区和深度缓冲区相当于这个像素“不存在”了。如果x大于等于0则正常继续后续的颜色计算和输出。那么这个x怎么来这就是我们实现不同溶解效果的关键。对于沿Y轴的溶解一个非常直观的思路是获取当前像素在世界空间中的Y坐标worldPos.y。定义一个全局的“溶解线”高度_DissolveThreshold。计算差值worldPos.y - _DissolveThreshold。将这个差值作为clip()函数的输入。当worldPos.y _DissolveThreshold时差值为负像素被裁剪。随着我们通过脚本将_DissolveThreshold从物体底部逐渐提高到顶部被裁剪的像素区域就从下至上蔓延形成了溶解动画。注意为什么常用世界空间坐标使用世界空间坐标World Space计算溶解其效果是全局统一的。无论物体如何移动、旋转溶解的“水平面”在世界中是固定的这通常符合我们的物理直觉比如物体沉入一个固定的水面之下。如果你希望溶解相对于物体自身发生比如物体旋转时溶解面也跟着旋转则应使用模型空间坐标Object Space。2.2 ShaderLab框架与属性块定义Unity的Shader使用ShaderLab语言编写它是一种声明式语言用于定义Shader的属性、子着色器、通道等。我们首先搭建一个基本的Unlit无光照Shader框架因为它结构简单适合专注于效果本身。Shader Custom/DissolveAlongYAxis { Properties { // 主纹理 _MainTex (Main Texture, 2D) white {} // 主颜色 _Color (Color, Color) (1,1,1,1) // 溶解相关属性 _DissolveThreshold (Dissolve Threshold, Range(-1, 2)) 0.0 _DissolveEdgeWidth (Edge Width, Range(0.0, 1.0)) 0.1 _DissolveEdgeColor (Edge Color, Color) (1, 0.5, 0, 1) _DissolveNoiseTex (Noise Texture (R), 2D) white {} _NoiseStrength (Noise Strength, Range(0, 1)) 0.1 } SubShader { Tags { RenderTypeOpaque QueueGeometry} LOD 100 Cull Off // 关闭背面剔除让溶解边缘内外都可见 Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc // 后续代码将在这里编写 ENDCG } } FallBack Diffuse }属性解析_DissolveThreshold: 这是驱动动画的核心参数。Range(-1, 2)的范围设定考虑了物体可能位于世界原点上下。初始值0代表从世界Y0处开始溶解。_DissolveEdgeWidth: 溶解边缘的宽度。纯硬裁剪的边界会很生硬我们希望在阈值附近创造一个过渡带让边缘有发光或渐变效果。_DissolveEdgeColor: 过渡带的颜色通常设置为亮橙色、蓝色等来模拟燃烧或能量消散。_DissolveNoiseTex和_NoiseStrength: 这是让效果更真实的关键。如果只用纯粹的Y轴比较溶解边缘会是一条完美的水平线看起来很假。我们引入一张噪声图单通道即可R通道来扰动溶解阈值让边缘产生破碎、随机的感觉。_NoiseStrength控制扰动的强度。实操心得在SubShader中设置Cull Off非常重要。默认的Cull Back会剔除背面当物体从内部开始溶解时你可能看不到内部的溶解面。关闭剔除后物体的内外表面都会渲染溶解效果会更加完整尤其是在飞行视角或物体内部可见的情况下。但这会带来一定的性能开销如果确定效果不需要可以保持默认。3. 顶点与片段着色器实现详解3.1 数据结构传递从CPU到GPU在CGPROGRAM块内我们首先需要定义连接顶点着色器和片段着色器的数据结构。// 定义从CPU应用阶段传递到顶点着色器的数据 struct appdata { float4 vertex : POSITION; // 模型空间的顶点位置 float2 uv : TEXCOORD0; // 主纹理UV float3 normal : NORMAL; // 法线可选用于边缘计算或光照 }; // 定义从顶点着色器传递到片段着色器的数据 struct v2f { float2 uv : TEXCOORD0; // 传递UV float4 worldPos : TEXCOORD1; // 计算并传递世界空间位置至关重要 float4 vertex : SV_POSITION; // 裁剪空间的位置必须 };这里的关键是v2f结构体中的worldPos。我们需要在顶点着色器中计算出每个顶点的世界坐标然后通过插值器interpolatorTEXCOORD1传递给片段着色器。这样在片段着色器中我们才能获取到每个像素精确的世界Y坐标。3.2 顶点着色器坐标空间转换顶点着色器的任务相对固定将顶点位置从模型空间转换到裁剪空间以供光栅化使用同时计算并传递我们需要的其他数据。v2f vert (appdata v) { v2f o; // 将顶点从模型空间转换到裁剪空间这是标准操作 o.vertex UnityObjectToClipPos(v.vertex); // 传递纹理UV o.uv v.uv; // 计算顶点的世界空间坐标并存储在.w分量中备用 o.worldPos mul(unity_ObjectToWorld, v.vertex); return o; }UnityObjectToClipPos是UnityCG.cginc中提供的宏它等价于mul(UNITY_MATRIX_VP, mul(unity_ObjectToWorld, float4(v.vertex.xyz, 1.0)))一次性完成模型-世界-观察-裁剪空间的转换。unity_ObjectToWorld是内置的模型-世界矩阵。3.3 片段着色器核心溶解逻辑这里是所有魔法发生的地方。我们将逐步构建片段着色器。第一步声明与采样首先我们需要声明在Properties块中定义的属性使其在CG代码中可用。// 在CGPROGRAM内部顶点着色器定义之前或之后声明这些变量 sampler2D _MainTex; float4 _MainTex_ST; // 纹理的缩放偏移值 fixed4 _Color; float _DissolveThreshold; float _DissolveEdgeWidth; fixed4 _DissolveEdgeColor; sampler2D _DissolveNoiseTex; float _NoiseStrength;第二步基础溶解与噪声扰动接下来是片段着色器frag函数的主体。fixed4 frag (v2f i) : SV_Target { // 1. 采样主纹理并应用颜色 fixed4 col tex2D(_MainTex, i.uv) * _Color; // 2. 获取当前像素的世界Y坐标 float worldY i.worldPos.y; // 3. 采样噪声图用于扰动溶解阈值 // 这里使用世界XZ坐标来采样噪声避免UV拉伸问题。_Time.y用于让噪声动起来。 float2 noiseUV i.worldPos.xz * 0.1 _Time.y * 0.2; float noiseValue tex2D(_DissolveNoiseTex, noiseUV).r; // 将噪声值从[0,1]映射到[-_NoiseStrength, _NoiseStrength] float noise (noiseValue * 2 - 1) * _NoiseStrength; // 4. 计算经过噪声扰动后的溶解边界 float dissolveBoundary _DissolveThreshold noise; // 5. 核心裁剪操作计算像素高度与边界的差值 float dissolveFactor worldY - dissolveBoundary; // 如果差值小于0像素被丢弃 clip(dissolveFactor); // 6. 计算边缘过渡区域 // edgeFactor在边界处为0高于边界_DissolveEdgeWidth后趋于1 float edgeFactor saturate(dissolveFactor / _DissolveEdgeWidth); // 7. 混合边缘颜色 // 当edgeFactor很小时靠近边界混合更多边缘色 fixed4 finalColor lerp(_DissolveEdgeColor, col, edgeFactor); return finalColor; }代码深度解析噪声采样技巧i.worldPos.xz * 0.1是将世界XZ坐标缩小10倍作为噪声UV。这避免了使用模型UV可能带来的拉伸。乘以一个小的系数如0.1是控制噪声的“密度”或“缩放”。_Time.y是自游戏开始以来的时间秒乘以一个速度系数0.2后加到UV上让噪声随时间流动从而使溶解边缘产生动态的、火焰般的扰动效果。噪声映射noiseValue来自纹理范围是[0,1]。(noiseValue * 2 - 1)将其映射到[-1, 1]再乘以_NoiseStrength得到最终的扰动值noise。这意味着溶解边界会在_DissolveThreshold上下波动。clip函数clip(dissolveFactor)是核心。当worldY小于扰动后的边界时dissolveFactor为负像素被丢弃。边缘过渡计算saturate(dissolveFactor / _DissolveEdgeWidth)是创造平滑边缘的关键。saturate函数将结果钳制在[0,1]。当像素高度worldY刚好等于边界dissolveBoundary时dissolveFactor 0edgeFactor 0。当像素高度比边界高出_DissolveEdgeWidth个单位时dissolveFactor / _DissolveEdgeWidth 1edgeFactor 1。在这之间edgeFactor是一个从0到1的线性渐变值。我们用它来混合边缘色和物体本色。颜色混合lerp(a, b, t)是线性插值函数当t0时返回at1时返回b。这里t edgeFactor所以在边界处edgeFactor0返回纯边缘色远离边界edgeFactor1返回物体原色中间是平滑过渡。注意事项关于性能与discard使用clip()或HLSL中的discard指令会迫使GPU跳过当前像素的后续处理如深度写入、模板测试等这可能会打断GPU的早期深度测试和像素着色器的并行优化在某些移动平台或复杂场景中可能对性能有影响。对于简单的溶解效果这通常不是问题。但如果性能极其敏感可以考虑使用alpha blending并设置alpha0来模拟溶解但这需要开启混合并正确处理渲染顺序。4. 效果增强与高级技巧基础的Y轴溶解已经完成了但要让它在项目中脱颖而出还需要一些“抛光”和扩展。4.1 添加边缘发光与扰动动画上面的代码已经有了动态噪声边缘。我们可以进一步增强它比如让边缘更亮或者添加第二层噪声。// 在frag函数中clip操作之后... // ... 计算edgeFactor ... // 增强边缘发光根据edgeFactor的倒数关系让靠近边界的地方更亮 float glow 1.0 - edgeFactor; // 边界处为1远离为0 glow pow(glow, 3); // 使用pow函数让发光衰减更尖锐 _DissolveEdgeColor.rgb glow * 2.0; // 提高边缘色亮度 // 使用第二层噪声以不同速度和尺度采样创造更复杂的扰动 float2 noiseUV2 i.worldPos.xz * 0.05 _Time.y * 0.5; float noiseValue2 tex2D(_DissolveNoiseTex, noiseUV2).r; float noise2 (noiseValue2 * 2 - 1) * (_NoiseStrength * 0.5); // 将两层噪声叠加 dissolveBoundary _DissolveThreshold noise noise2; // 重新计算dissolveFactor和edgeFactor注意性能这里为了清晰分两步 dissolveFactor worldY - dissolveBoundary; clip(dissolveFactor); edgeFactor saturate(dissolveFactor / _DissolveEdgeWidth);4.2 使用模型空间坐标的变体有时世界空间溶解并不合适。例如你想做一个角色自身从脚到头溶解的效果并且希望这个效果不随角色在世界中移动而改变即溶解面始终在角色的脚部。这时就需要使用模型空间坐标。修改非常简单在appdata结构体中我们已经有了模型空间顶点位置v.vertex。在顶点着色器中直接将v.vertex的y分量传递给v2f结构体。在片段着色器中使用传递过来的模型空间y坐标进行计算。// v2f结构体 struct v2f { float2 uv : TEXCOORD0; float modelY : TEXCOORD1; // 改为传递模型空间Y float4 vertex : SV_POSITION; }; // 顶点着色器 v2f vert (appdata v) { v2f o; o.vertex UnityObjectToClipPos(v.vertex); o.uv v.uv; o.modelY v.vertex.y; // 传递模型Y return o; } // 片段着色器 fixed4 frag (v2f i) : SV_Target { // ... float dissolveFactor i.modelY - _DissolveThreshold; // 使用模型Y // ... }注意模型空间坐标通常需要根据模型的原始中心进行归一化处理比如除以模型的包围盒高度才能使阈值范围如0到1对应从底到顶的溶解。这通常需要在脚本中计算模型高度并传递给Shader一个比例参数。4.3 与粒子系统结合创造消散粒子纯Shader溶解的边界是平滑的即使有噪声。要创造粒子飞散的效果需要结合Unity的粒子系统。思路是在Shader中我们不仅输出颜色还可以输出一个“溶解度”信息到渲染纹理Render Texture或使用Custom Data通道。创建一个粒子系统其发射器形状与溶解物体相同。使用Position over Lifetime模块并选择从Custom Data或通过脚本读取的溶解边界信息让粒子只在已溶解的区域附近生成和发射。粒子的初始速度可以设置为从溶解表面法线方向弹出。这是一个更高级的主题涉及到Shader与渲染管线的深度交互。一个更实用的简化方案是在物体溶解动画播放的同时在物体当前位置实例化一个预制的粒子系统并让粒子系统播放一次性的爆发Burst效果也能达到不错的效果。5. 在Unity中的实际应用与脚本控制Shader写好了但它是静态的。我们需要通过C#脚本来驱动_DissolveThreshold变化从而产生动画。5.1 创建材质与基础设置在Project窗口中右键创建 - Material命名为Mat_DissolveY。将Shader选择为我们刚写的Custom/DissolveAlongYAxis。为其指定_MainTex主纹理和_DissolveNoiseTex噪声纹理。噪声纹理推荐使用Perlin噪声或类似的有连续性的灰度图。调整_DissolveEdgeWidth和_DissolveEdgeColor到你满意的视觉效果。5.2 编写控制脚本创建一个C#脚本DissolveController.cs。using UnityEngine; public class DissolveController : MonoBehaviour { public Material dissolveMaterial; // 拖入使用该Shader的材质实例 public float dissolveDuration 2.0f; // 溶解总时长 public bool startDissolveOnStart false; public float startThreshold -1f; // 溶解起始高度物体底部以下 public float endThreshold 2f; // 溶解结束高度物体顶部以上 private float _currentThreshold; private bool _isDissolving false; private float _timer 0f; void Start() { if (dissolveMaterial null) { // 尝试从当前GameObject的Renderer上获取材质 Renderer rend GetComponentRenderer(); if (rend ! null) dissolveMaterial rend.material; } // 初始化阈值 _currentThreshold startThreshold; if (dissolveMaterial ! null) dissolveMaterial.SetFloat(_DissolveThreshold, _currentThreshold); if (startDissolveOnStart) StartDissolve(); } void Update() { if (!_isDissolving) return; _timer Time.deltaTime; float progress Mathf.Clamp01(_timer / dissolveDuration); // 使用插值计算当前阈值 _currentThreshold Mathf.Lerp(startThreshold, endThreshold, progress); if (dissolveMaterial ! null) dissolveMaterial.SetFloat(_DissolveThreshold, _currentThreshold); // 溶解完成后可以销毁物体或触发事件 if (progress 1.0f) { _isDissolving false; OnDissolveComplete(); } } // 外部调用此方法开始溶解 public void StartDissolve() { if (_isDissolving) return; _isDissolving true; _timer 0f; } private void OnDissolveComplete() { Debug.Log(gameObject.name 溶解完成); // 例如销毁物体、播放声音、触发游戏逻辑等 // Destroy(gameObject); } // 在Inspector中调试用 [ContextMenu(测试溶解)] void TestDissolve() { StartDissolve(); } }脚本关键点Mathf.Lerp线性插值是控制动画平滑度的核心。你可以替换为Mathf.SmoothStep来获得缓入缓出的效果。Material.SetFloat这是C#脚本与Shader通信的标准方式通过属性名称字符串来设置其值。确保字符串与Shader中定义的属性名完全一致。材质实例化在Start方法中我们通过GetComponentRenderer().material获取材质。注意这会创建该材质的一个实例Instance修改这个实例不会影响其他使用同一材质的物体。这是通常我们想要的行为。如果你希望所有使用该材质的物体一起溶解则应使用sharedMaterial但需谨慎。5.3 动画系统控制除了脚本用Unity的Animation系统控制阈值更直观尤其对于复杂的、需要与其他变换同步的动画。选中你的物体打开Animation窗口Window - Animation - Animation。创建一个新的动画剪辑如Dissolve.anim。在动画时间线上为材质属性_DissolveThreshold添加关键帧。在第0帧设置值为startThreshold如-1。在最后一帧如第60帧代表2秒设置值为endThreshold如2。你可以调整曲线编辑器让阈值的变化不是线性的例如先慢后快。通过Animator控制器或Animation.Play()方法来播放这个动画。使用动画系统的优点是可视化强可以轻松制作非线性、复杂的溶解序列并且能与物体的移动、旋转动画完美同步。6. 常见问题、优化与排查技巧在实际使用中你可能会遇到一些典型问题。这里记录了我踩过的坑和解决方案。6.1 问题排查速查表问题现象可能原因解决方案物体完全不显示或瞬间消失_DissolveThreshold初始值设置过高高于物体所有顶点的世界Y坐标。检查脚本或材质中_DissolveThreshold的初始值。确保它低于物体的最低点通常设为-1或更小。在Scene视图运行时观察该值的变化。溶解边缘是一条完美的水平硬线没有使用噪声纹理或者_NoiseStrength设置为0。确保已为_DissolveNoiseTex分配了一张噪声图并将_NoiseStrength调大到0.1以上。检查噪声图导入设置确保它是单通道R且非压缩格式如RGBA32以获得更好效果。边缘颜色不显示或过渡生硬_DissolveEdgeWidth设置过小接近0。增大_DissolveEdgeWidth值例如0.05到0.3之间。确保片段着色器中计算edgeFactor时除数不为零。溶解时物体内部背面看不到效果Shader启用了背面剔除Cull Back。在SubShader或Pass中添加Cull Off指令。注意性能影响如果不需要可保持默认。噪声纹理在物体上拉伸严重使用模型UV(i.uv)采样噪声而模型UV可能被用于主纹理且布局不佳。改用世界坐标(i.worldPos.xz)或屏幕坐标来采样噪声并乘以一个小的缩放系数。移动物体时溶解边缘“抖动”或变化使用世界坐标采样噪声但物体移动时世界坐标变化导致噪声采样点变化。这是预期行为创造了动态边缘效果。如果希望边缘相对于物体静止应使用模型空间坐标模型空间Y阈值或者使用物体世界坐标的偏移量来采样噪声。在移动设备上性能不佳使用了clip指令且物体面数很高或者片段着色器计算复杂。1. 考虑使用AlphaTest或简单的透明度混合替代clip。2. 简化噪声计算只用一层。3. 确保噪声纹理尺寸合理如512x512。4. 对不重要的物体使用简化的Shader变体。溶解完成后物体阴影还残留裁剪掉的像素不写入深度但阴影投射Pass可能没有使用相同的裁剪逻辑。你需要为阴影投射ShadowCasterPass也实现相同的溶解裁剪逻辑。通常做法是复制一个最简单的、只做clip操作的Pass并为其添加LightMode ShadowCaster标签。6.2 性能优化建议纹理优化噪声纹理可以使用较小的尺寸如256x256和低精度的格式如ASTC 4x4。如果不需要彩色边缘_DissolveNoiseTex可以使用单通道纹理。计算优化在片段着色器中噪声计算是性能消耗点。如果效果要求不高可以移除第二层噪声和复杂的边缘发光计算。甚至可以考虑在顶点着色器中计算溶解因子然后通过插值传递给片段着色器但这会降低边缘精度。LOD细节层次为Shader设置不同的LOD值并为低端设备编写一个简化版本例如去掉噪声只用硬边缘溶解在SubShader中使用LOD指令进行控制。批处理Batching动态修改材质属性如_DissolveThreshold会打断GPU实例化GPU Instancing。如果场景中有大量同时溶解的相同物体考虑使用材质属性块MaterialPropertyBlock来单独设置属性这样不会创建新的材质实例且能保持合批。6.3 扩展思路掌握了基础之后你可以尝试以下扩展让效果更具创意多轴混合溶解不只是Y轴可以结合X、Z轴甚至使用点乘dot来定义任意方向的溶解平面。基于距离的溶解计算像素到某个中心点的距离实现从中心向四周或从四周向中心的溶解。纹理遮罩溶解使用一张额外的遮罩纹理Mask Texture来控制哪些区域可以溶解。比如只有盔甲上的红色区域会溶解绿色区域保持完好。溶解后残留骨架第一个Pass渲染溶解效果第二个Pass用不同的Shader如只渲染边缘线框渲染未被溶解的部分形成骨架残留的视觉效果。与后处理结合将溶解因子写入到自定义的渲染缓冲区如Camera的DepthNormal纹理的Alpha通道然后在后处理中全局地添加辉光、扭曲等效果使溶解与场景的互动更强。实现一个效果只是起点理解其原理并能根据项目需求调整、优化和扩展才是Shader学习的真正目的。这个沿Y轴溶解的Shader是一个非常好的入门和模板希望你通过它能打开Unity图形编程的大门。