溶解几乎是每个游戏都会用到的特效:角色死亡化成灰、道具被传送走、场景里的东西被“烧”掉。网上的写法很多,但大多数只讲了 clip 那一行,放进 URP 项目一跑就会发现:物体溶掉了,地上的影子还是完整的。
这篇用 Unity 2022.3 + URP 14 从头写一个能直接用的溶解 Shader,把几个常见的坑一次讲清楚:
- 溶解的原理:为什么本质是 Alpha Test
- 进度为 0 时有杂点、为 1 时有残留怎么办
- 影子、深度图怎么跟着一起溶
- 燃烧的发光边怎么做,为什么 HDR 颜色“怎么调都发白”
文末有在线预览(浏览器里直接拖进度条看效果)和完整源码。
一、溶解就是带阈值的 Alpha Test
给表面每个点一个 0~1 的“溶解值” v(最简单就是采样一张噪声图),再给一个全局进度 p。规则只有一条:
v < p 的像素丢掉。
进度从 0 涨到 1,越来越多的像素满足条件,物体就从噪声值最小的地方开始破洞,最后完全消失。HLSL 里丢像素用 clip(x):参数小于 0 时,这个片元不写颜色、不写深度。
所以溶解后的物体仍然是不透明的——每个像素要么完整存在,要么完全不存在。它不需要透明队列,也不需要排序,用 "Queue"="AlphaTest"(2450)就行:让完全不透明的物体先画、先写好深度,更多被挡住的溶解像素能被提前剔除。
进度从左到右 0.15、0.4、0.6、0.8。注意地上的影子和物体同步镂空,背面的内壁被压暗。
二、坑 1:进度 0 有杂点,进度 1 有残留
噪声贴图的取值很少真正到 0 或 1。直接 clip(v - p),进度为 0 时可能已经裁掉了几个像素,进度为 1 时还剩几个“顽固像素”。办法是把进度区间稍微外扩:
// 返回 0~1 的“溶解值”:值越小越早消失
half DissolveValue(float2 uv)
{
return SAMPLE_TEXTURE2D(_NoiseMap, sampler_NoiseMap, uv * _NoiseMap_ST.xy + _NoiseMap_ST.zw).r;
}
// 把进度从 [0,1] 稍微外扩,保证 0 时完全可见、1 时完全消失
half DissolveClip(half value, out half edge)
{
half p = _Progress * (1.0h + _EdgeWidth + 0.02h) - _EdgeWidth - 0.01h;
half d = value - p; // d < 0:已经溶掉
clip(d);
// 消失前沿附近的一条带子:d 从 0 到 EdgeWidth,edge 从 1 降到 0
edge = 1.0h - saturate(d / max(_EdgeWidth, 1e-4h));
return d;
}
外扩量里还算上了发光边的宽度 _EdgeWidth,保证进度为 0 时连发光边都不出现。函数顺便输出 edge(离溶解前沿有多近),下面做发光要用。
三、坑 2:影子没有跟着溶
这是最常见的问题。只在 Forward Pass 里 clip,屏幕上的物体会破洞,但阴影贴图是 ShadowCaster Pass 画的,它不知道你裁掉了什么,影子还是完整的。开了 SSAO 或者用到深度图的话,深度图里也会留下一个完整的“幽灵”。
解决办法:溶解计算写成公共函数,放进一个 .hlsl,所有写深度的 Pass(ForwardLit、ShadowCaster、DepthOnly)都调用它。
// ShadowCaster / DepthOnly 的片元只做一件事:同样算一遍然后 clip
half4 fragClip(Varyings i) : SV_Target
{
half edge;
DissolveClip(DissolveValue(i.uv), edge);
return 0;
}
两个顺带的注意点:
- 材质参数(CBUFFER)也要放在这个公共文件里。 同一个 Shader 所有 Pass 的
UnityPerMaterial布局必须完全一样,否则 SRP Batcher 会判这个 Shader 不兼容。也别用UsePass "Universal Render Pipeline/Lit/ShadowCaster"偷懒,借来的 Pass 带着 Lit 自己的 CBUFFER,一样会破坏合批。 - 自己写 ShadowCaster 时,只 include
Shadows.hlsl会报undeclared identifier 'LerpWhiteTo'。 Lit 是通过 LitInput.hlsl 间接包含了它,自己写要先 includePackages/com.unity.render-pipelines.core/ShaderLibrary/CommonMaterial.hlsl。
四、坑 3:破洞后看起来像纸壳
物体被挖出洞以后,会透过洞看到它的内壁。默认 Cull Back 把内壁剔掉了,看起来像一张纸。把剔除改成 Cull Off,再在片元里判断正反面:背面翻转法线、压暗颜色,就像一个被掏空的实心物体。
half4 frag(Varyings i, FRONT_FACE_TYPE face : FRONT_FACE_SEMANTIC) : SV_Target
{
half edge;
DissolveClip(DissolveValue(i.uv), edge);
half3 albedo = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, i.uv).rgb * _BaseColor.rgb;
// 背面(内壁):法线翻过来,颜色压暗
half3 normalWS = normalize(i.normalWS) * IS_FRONT_VFACE(face, 1.0h, -1.0h);
albedo *= IS_FRONT_VFACE(face, 1.0h, 0.35h);
...
}
五、燃烧的发光边
记 d = v − p:d 刚好大于 0 的像素“即将被溶掉”,它们就是前沿。上面的 edge = 1 − saturate(d / w) 在前沿处是 1,往里 w 的距离降到 0。用它去查一张从暗红到亮黄的 Ramp 贴图,就是燃烧的颜色分布:
// 前沿附近先“烧焦”变暗,再在最前沿发光
albedo *= 1.0h - 0.85h * smoothstep(0.0h, 0.6h, edge);
half3 color = SimpleLighting(albedo, i.positionWS, normalWS, _Smoothness);
half3 glow = _EdgeColor.rgb * SAMPLE_TEXTURE2D(_EdgeRamp, sampler_EdgeRamp, float2(edge, 0.5)).rgb;
// edge 的平方:亮度集中在最前沿,往里快速衰减
color += glow * edge * edge;
两个细节让效果可信很多:
- 乘
edge * edge而不是edge:平方让光集中成一条细亮线,而不是一片光晕。 - 前沿后面先压暗:模拟纸张燃烧时火线前面先烤焦的那一圈,比单纯加一条亮边真实得多。
为什么发光颜色“怎么调都发白”
_EdgeColor 要标 [HDR],分量可以填到 4、5。URP 开 HDR 时颜色缓冲是浮点格式,能存下大于 1 的值,后处理 Bloom 把超出阈值的部分模糊、叠回画面,才有“溢光”。
没开后处理(或者相机没勾 Post Processing)时,大于 1 的颜色在输出时被截断成白色,你只能看到一条刺眼的白边。所以要在 Volume 里加 Bloom(阈值 1 左右)和 Tonemapping,相机勾上 Post Processing。
换一种溶解值就是换一种效果。左:噪声;中:沿方向从上往下(掺了一点噪声让切口不规则);右:从一个点向外扩散,适合“被击中的位置先开始瓦解”。
六、几个调参的经验
- 噪声跟着 UV 走:UV 拉伸的地方斑块也会被拉长。想要均匀的斑块,用三平面采样或对象空间 3D 噪声。
- 进度变化不匀速:fBm 噪声的值大多集中在中间,进度在 0.3~0.7 时变化最快。想要匀速,在 C# 里用 AnimationCurve 驱动进度。
- 远处溶解边缘糊掉:低级 mip 把噪声平均成一片灰。噪声贴图关掉 mipmap,或用
SAMPLE_TEXTURE2D_LOD限制级别。 - 锯齿:
clip是硬边,MSAA 救不了它(MSAA 只对几何边缘多采样)。可以用 Alpha to Coverage 或后处理抗锯齿。 - 运行时改参数:单个物体用材质实例或 MaterialPropertyBlock;注意 MaterialPropertyBlock 会让物体退出 SRP Batcher 合批,大量物体同时溶解时更推荐材质实例。
在线预览和完整源码
- 在线预览(浏览器里直接拖进度、改发光颜色,还能改代码实时看效果):
- 完整 Shader(含方向溶解、球形溶解、ShadowCaster / DepthOnly 三个 Pass):github.com/sladeByZsl/…
这篇是《URP 渲染实战》第 18 章的一部分。整本书 30 章、两百多个案例都在网上免费公开,每张图都能在浏览器里实时调参:gametech.byte888.com/urp/
有问题欢迎评论区交流。