ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Unity冰冻Shader实战:三平面遮罩、HLSL与移动端优化

2026/9/18 7:31:23 拓冰建站 浏览量
Unity冰冻Shader实战:三平面遮罩、HLSL与移动端优化 冰冻效果算是 unity shader 里最经得起反复琢磨的案例之一。它不像纯 PBR 那样只是套公式也不像纯后处理那样只改屏幕像素而是把噪声遮罩、法线扰动、折射、边缘光、顶点位移几条线拧在一起最后呈现出一个从无到有、慢慢爬满的过程。我这两年在几个带环境交互的项目里前后写过四五个版本的冰冻 shader从最早只会用两张贴图 lerp 的粗暴做法到后来拆成遮罩层、冰壳层、内透层三层叠加中间踩的坑比代码行数还多。这篇就把我这套东西完整摊开结冰进度怎么驱动、霜冻纹理为什么非用三平面映射不可、手写 HLSL 的每一段在干什么、搬到 URP 之后哪些地方必须改、移动端和微信小游戏怎么砍开销。如果你刚开始碰 shader或者想找一个能直接落到项目里的冰冻方案下面的内容可以直接抄。1. 冰冻效果的技术拆解与方案选型1.1 先搞清楚冰到底由哪几层视觉信息组成我得先说明白一件事冰冻效果不是一个 shader 特效它是一个由好几层叠加出来的视觉结果。很多人第一次做的时候直接拿一张冰的贴图往模型上一贴结果就是模型换了个皮肤完全没有结冰的感觉。问题出在他们只做了颜色层漏掉了另外几层。我的拆法是分成四层。遮罩层决定哪里结了冰、边缘长什么样材质层负责冰本身的颜色、粗糙度、透光感光学层负责边缘光、高光、环境反射、折射几何层负责结冰时体积膨胀、冰锥、法线被霜花顶起的凹凸。这四层里遮罩层是地基它一旦不自然后面三层做得再漂亮都白搭。因为人眼判断结冰这个动作靠的不是冰的颜色而是那个蔓延边缘的形态——有机的、不规则的、带着细碎霜花的边界。然后是驱动方式。我见过两种做法一种是时间驱动定时器一开_FreezeAmount从 0 涨到 1模型就慢慢冻住另一种是事件驱动比如角色进了低温区域、被冰系技能命中就调一次材质参数。这两种在实现上没有区别区别在于_FreezeAmount这个参数由谁来推进、要不要做缓动。我的经验是无论哪种都别让_FreezeAmount线性增长给它套一个Mathf.SmoothStep或者自定义的缓动曲线前段慢、中段快、后段收尾观感立刻上一个档次。真实的结冰过程本来就是先在某些点成核然后快速蔓延最后在边界处减速。还有一个经常被忽略的点冰冻效果的时间尺度。同一个 shader用在技能特效上是 0.3 秒用在环境结霜上是 3 秒两者的参数取值完全不同。前者要锐利、要突然后者要柔和、要能看出来在爬。所以参数面板上最好把驱动参数和外观参数分开摆不然美术一上手就会把两者混着调。1.2 三条技术路线分别适合什么场景方案选型这块我吃过亏所以想展开讲讲。做冰冻效果大致有三条路复杂度从低到高。第一条是纯颜色替换用一张冰的贴图和原贴图做 lerp再叠一层边缘光。写起来可能不到一百行性能开销几乎为零。它适合的场景其实是远景物件和大量重复的小物件比如场景里的路灯、栏杆、地面的碎石几十上百个一起冻住的时候用这套足够。缺点是近看很假因为没有厚度和折射。第二条是遮罩驱动加光学增强就是我上面说的四层结构用噪声遮罩控制结冰范围冰壳部分叠加 fresnel、高光、环境反射再加上法线扰动做霜花的凹凸。这是最主流的做法也是今天要完整写出来的版本。它的成本主要在纹理采样上一个像素大概要采 4 到 8 次移动端要小心。第三条是加几何和折射在第二条的基础上再加顶点膨胀、冰锥模型、抓屏折射。观感最好成本也最高。抓屏折射在移动端基本等于每帧多一次全屏拷贝除非你的项目是写实向的主机或者 PC 游戏否则我一般不建议开。真要做折射感用一张 cubemap 做伪折射更划算。所以选型的时候先问自己一句这个冰冻效果是给玩家贴脸看的还是隔着十米看的是主角身上的持续状态还是场景里一堆道具的静态装饰答案不同方案能差一个量级。2. 结冰遮罩整套效果的地基2.1 为什么我最后选了三平面映射先说一个最常见的坑。新手做结冰遮罩一般会直接用模型的 UV 去采样噪声图写出来大概是tex2D(_FrostTex, i.uv * _FrostTiling)。这在单个立方体或者球上看着没问题一旦换成角色模型、建筑、地形问题全冒出来了UV 接缝处会出现明显的断层霜冻蔓延到接缝就停住了UV 拉伸严重的地方比如圆柱体的侧面纹理会变成条状完全不像霜花更麻烦的是不同美术做的模型 UV 密度完全不一样同一个材质参数在不同模型上表现天差地别。我试过用 UV 加扰动来糊效果一般。后来换成三平面映射Triplanar Mapping问题基本消失。原理不复杂不用 UV直接用世界坐标把空间里的三个平面YZ、XZ、XY分别采一次同一张噪声图然后按世界法线的三个分量做加权平均。法线朝 X 的方向YZ 平面的采样权重最大以此类推。这样得到的结果是连续的、没有接缝的、跟模型 UV 完全无关的。权重计算有个讲究。直接用法线分量加权的话在斜面上三个平面权重接近会出现糊的感觉通用的做法是取法线的 4 次方再归一化pow(abs(n), 4) / sum。次方越大混合区越窄过渡越干脆。我一般用 4立方体这种硬边模型在权重上是绝对占优的不会出现三个平面互相污染。有人用 8边缘会更干净但在球体上会出现明显的三块分区得权衡。还有一个细节贴图的 Wrap Mode 必须设成 Repeat否则物体会在纹理边界处出现一条硬缝。另外三平面映射的采样坐标是worldPos * tiling直接用世界坐标意味着物体一动霜花图案就会跟着流动——这在某些场景下是想要的比如角色在冰面上移动时霜花在飘但在大部分场景下是错的。解决办法不难用物体空间坐标mul(unity_WorldToObject, float4(worldPos,1))来做采样。这样纹理是焊死在模型上的物体怎么动图案都不变。采样空间图案是否随物体移动相邻物体能否自然衔接适用场景模型 UV不移动否接缝明显单个简单几何体世界坐标会流动能天然连续场景整体冰冻、数字孪生天气演示物体空间坐标焊死不动否各物体独立角色、道具、可移动物体2.2 smoothstep 才是让蔓延边缘好看的关键遮罩算出来之后怎么把它变成一个 0 到 1 的结冰程度这一步决定了整个效果的观感上限。我最早的做法是硬阈值iceMask frost _FreezeAmount ? 1 : 0。结果就是边缘像被刀切过一样锯齿感极强完全没有霜花那种毛茸茸的感觉。后来改成线性过渡saturate((_FreezeAmount - frost) / _Softness)好了一些但还是有点均匀因为噪声图本身的分布是固定的线性切割出来的是等距的条纹。真正让我满意的是smoothstepfloat iceMask smoothstep(frost - _Softness, frost _Softness, _FreezeAmount);这里smoothstep(a, b, x)的意思是x小于a时返回 0大于b时返回 1中间用三次平滑曲线过渡。把a和b设成frost ± _Softness就相当于沿着噪声图的等值线划出了一个带柔和过渡的蔓延带。_Softness控制这条带的宽度值越大边缘越化得开值越小越锐利。我的经验值范围是 0.08 到 0.2低于 0.05 就会开始出现明显的锯齿。这块还有个隐藏福利因为smoothstep的过渡是三次曲线边缘的中间段会有自然的加速和减速感看起来就特别像冰晶在扩散。这一点用线性插值是没有的。关于高度衰减。如果想让物体从下往上冻可以在驱动量上叠一层高度权重float heightDrive saturate(_FreezeAmount _HeightBias * (1.0 - saturate(i.worldPos.y * 0.5))); float iceMask smoothstep(frost - _Softness, frost _Softness, heightDrive);_HeightBias给个 0.1 到 0.3 就够了太大反而会让低处直接冻满、高处永远冻不上破坏蔓延感。我一般把它做成可选的默认 0需要的时候再开。2.3 冰层该不该有体积顶点膨胀的取舍结冰之后物体的轮廓会略微变胖这算是一个很微妙的观察。水结冰体积膨胀大约 9%视觉上其实看不出来但如果你把冰壳的厚度做出来边缘的轮廓会更有壳的感觉。具体做法就是在顶点着色器里沿法线方向外扩float expand _Expand * saturate(_FreezeAmount * 1.5 - 0.5); wpos wnor * expand;注意saturate(_FreezeAmount * 1.5 - 0.5)这一段它的作用是让膨胀在结冰进度过半之后才开始避免物体刚冻上一点就整体变胖。这个细节是我调了很久才定下来的直接线性乘的话前 30% 的进度膨胀就已经很明显了看起来像模型被人吹了口气。膨胀带来的副作用是法线会变得不准尤其是低模。对于硬表面模型影响不大角色模型如果顶点太少膨胀之后关节处会出现明显的拉伸。我的做法是给_Expand一个很小的值0.01 到 0.02只在需要夸张表现的时候才加到 0.05 以上。顶点阶段还有个限制拿不到逐像素的iceMask只能用_FreezeAmount和顶点的世界坐标做粗略估算。所以膨胀和结冰边缘是不同步的看起来有点整体先胖、局部再冻。要真正同步只能上几何着色器或者细分移动端的成本考虑基本就告别了。我的建议是接受这个不完美用很小的膨胀量把它藏过去。3. 手写 HLSL一套完整的 Built-in 冰冻 Shader3.1 属性块与整体结构先说结构。整个 Shader 我用一个 Pass 加一个前置的 GrabPass。之所以要 GrabPass是因为冰面折射必须要拿到冰层后面的画面固有不透明管线里最直接的办法就是把当前帧抓一张到_IceGrab。属性块里我习惯按用途分组摆放基础外观贴图、基础颜色、冰层外观冰颜色、霜冻噪声、密度、驱动参数结冰进度、柔和度、高度权重、光学参数边缘光、高光、折射、几何参数凹凸、膨胀。这样美术在面板上找参数的时候会比较顺尤其是参数多到十五六个的时候。提示GrabPass 在移动端和不支持渲染到纹理的场景里开销非常大微信小游戏这类平台对全屏抓取很不友好。真机上跑之前先把这一块做成开关或者干脆换成一张 cubemap 做伪折射。完整代码如下后面逐段拆解Shader Custom/Freeze/IceCoverage { Properties { _MainTex (基础贴图 (RGB), 2D) white {} _BaseColor (基础颜色, Color) (0.7,0.7,0.7,1) _IceColor (冰的颜色, Color) (0.55,0.82,1.0,1) _FrostTex (霜冻噪声 (R), 2D) gray {} _FrostTiling (霜冻密度, Float) 3.0 _FreezeAmount (结冰进度, Range(0,1)) 0.0 _FrostSoftness (蔓延边缘柔和度, Range(0.005,0.4)) 0.12 _HeightBias (高度衰减, Range(0,0.5)) 0.0 _RimPower (边缘光锐度, Range(0.5,8)) 3.0 _RimColor (边缘光颜色, Color) (0.75,0.95,1,1) _BumpScale (冰晶凹凸, Range(0,0.5)) 0.08 _Refract (折射强度, Range(0,0.15)) 0.04 _RefractTint (折射不透明度, Range(0,1)) 0.45 _Gloss (高光锐度, Range(1,256)) 80 _SpecIntensity (高光强度, Range(0,2)) 0.9 _Expand (顶点膨胀, Range(0,0.1)) 0.015 } SubShader { Tags { RenderTypeOpaque QueueGeometry } GrabPass { _IceGrab } Pass { Tags { LightModeForwardBase } CGPROGRAM #pragma vertex vert #pragma fragment frag #pragma multi_compile_fwdbase #pragma multi_compile_fog #include UnityCG.cginc #include Lighting.cginc #include AutoLight.cginc sampler2D _MainTex; float4 _MainTex_ST; sampler2D _FrostTex; sampler2D _IceGrab; fixed4 _BaseColor, _IceColor, _RimColor; float _FrostTiling, _FreezeAmount, _FrostSoftness, _HeightBias; float _RimPower, _BumpScale, _Refract, _RefractTint; float _Gloss, _SpecIntensity, _Expand; struct appdata { float4 vertex : POSITION; float3 normal : NORMAL; float2 uv : TEXCOORD0; }; struct v2f { float4 pos : SV_POSITION; float2 uv : TEXCOORD0; float3 worldPos : TEXCOORD1; float3 worldNormal : TEXCOORD2; float4 grabPos : TEXCOORD3; UNITY_FOG_COORDS(4) SHADOW_COORDS(5) }; // 三平面映射采样霜冻噪声 float SampleFrost(float3 wp, float3 wn) { float3 p wp * _FrostTiling; float3 blend pow(abs(wn), 4.0); blend / max(blend.x blend.y blend.z, 1e-4); float n tex2D(_FrostTex, p.yz).r * blend.x tex2D(_FrostTex, p.xz).r * blend.y tex2D(_FrostTex, p.xy).r * blend.z; return n; } v2f vert (appdata v) { v2f o; float3 wpos mul(unity_ObjectToWorld, v.vertex).xyz; float3 wnor UnityObjectToWorldNormal(v.normal); // 结冰过半后开始膨胀避免一开始就整体变胖 float heightFactor saturate(1.0 - _HeightBias * wpos.y); float expand _Expand * saturate(_FreezeAmount * 1.5 - 0.5) * heightFactor; wpos wnor * expand; o.pos mul(UNITY_MATRIX_VP, float4(wpos, 1.0)); o.worldPos wpos; o.worldNormal wnor; o.uv TRANSFORM_TEX(v.uv, _MainTex); o.grabPos ComputeGrabScreenPos(o.pos); // 必须在顶点位置定稿后调用 UNITY_TRANSFER_FOG(o, o.pos); TRANSFER_SHADOW(o); return o; } fixed4 frag (v2f i) : SV_Target { float3 N normalize(i.worldNormal); float3 V normalize(_WorldSpaceCameraPos - i.worldPos); // ---- 1. 结冰遮罩 ---- float frost SampleFrost(i.worldPos, N); float drive saturate(_FreezeAmount _HeightBias * (1.0 - saturate(i.worldPos.y * 0.5))); float iceMask smoothstep(frost - _FrostSoftness, frost _FrostSoftness, drive); // ---- 2. 无参数曲面凹凸制造冰晶细碎质感 ---- float3 dpdx ddx(i.worldPos); float3 dpdy ddy(i.worldPos); float fx SampleFrost(i.worldPos dpdx, N); float fy SampleFrost(i.worldPos dpdy, N); float dfdx fx - frost; float dfdy fy - frost; float3 r1 cross(dpdy, N); float3 r2 cross(N, dpdx); float det dot(dpdx, r1); det (abs(det) 1e-6) ? 1e-6 : det; // 保护防止 sign 抖动 float3 surfGrad sign(det) * (dfdx * r1 dfdy * r2); float3 bumpN normalize(abs(det) * N - _BumpScale * surfGrad); N normalize(lerp(N, bumpN, iceMask)); // ---- 3. 光学计算 ---- float3 L normalize(_WorldSpaceLightPos0.xyz); float3 H normalize(L V); float NdotL saturate(dot(N, L)); float fres pow(1.0 - saturate(dot(N, V)), _RimPower); float spec pow(saturate(dot(N, H)), _Gloss) * _SpecIntensity; float3 albedo tex2D(_MainTex, i.uv).rgb * _BaseColor.rgb; float3 iceCol _IceColor.rgb * (0.35 0.65 * NdotL) _RimColor.rgb * fres * 1.6 spec; // ---- 4. 光照与阴影 ---- float3 ambient ShadeSH9(float4(N, 1)); float3 baseLit albedo * (NdotL * _LightColor0.rgb ambient); float3 iceLit iceCol * (_LightColor0.rgb * NdotL * 0.6 ambient * 1.2); UNITY_LIGHT_ATTENUATION(atten, i, i.worldPos); baseLit * atten; iceLit * atten; // ---- 5. 折射与合成 ---- float2 screenUV i.grabPos.xy / i.grabPos.w; screenUV N.xz * _Refract * iceMask; float3 sceneCol tex2D(_IceGrab, screenUV).rgb; iceLit lerp(iceLit, sceneCol * _IceColor.rgb * 1.15, _RefractTint); float3 col lerp(baseLit, iceLit, iceMask); UNITY_APPLY_FOG(i.fogCoord, col); return fixed4(col, 1.0); } ENDCG } } FallBack Diffuse }3.2 顶点着色器里的两个关键点顶点着色器主要干三件事把顶点转到世界空间、算抓屏坐标、传插值数据。第一个关键是抓屏坐标ComputeGrabScreenPos(o.pos)。这里有个细节很容易踩坑它必须在顶点的裁剪空间位置最终确定之后调用。也就是说如果你在之前已经把顶点位置修改过了比如做了膨胀那就得用修改后的o.pos去算如果在之后又改了位置那抓屏就偏了。我见过一个案例就是膨胀的代码写在ComputeGrabScreenPos后面结果冰层一膨胀贴到屏幕上的折射画面就整体歪了半个像素的量化误差其实是大问题。第二个关键是SHADOW_COORDS的编号。Shader 里的TEXCOORD插值器是有限资源用UNITY_FOG_COORDS(4)和SHADOW_COORDS(5)的时候前面已经用到了 0 到 3不能冲突。这个在 PC 上不一定报错但在移动端 GLES2 上插值器超限就直接编译失败。如果项目要覆盖低端机最好把worldNormal和worldPos合并一下或者把视角方向丢掉改用_WorldSpaceCameraPos - worldPos在片元里现算。后者的代价是每个像素多一次减法但能省一个插值器挺划算的。法线的空间转换也值得说一句。UnityObjectToWorldNormal和mul(unity_ObjectToWorld, float4(normal,0)).xyz看着结果差不多但前者在非均匀缩放的模型上才是对的。如果物体被压扁或拉长过用后者算出来的法线会偏冰面的高光和边缘光位置就会歪。这跟 unity 阴影问题里常见的法线错误是同一个根源——法线没有走逆转置矩阵。3.3 片元着色器从噪声到最终颜色的完整链路片元着色器是整段代码里最长的部分我按执行顺序拆成五步来看。第一步算结冰遮罩。这个在第 2 节已经写过了先采一次三平面噪声然后用smoothstep得到iceMask。第二步算法线扰动。这段用的是无参数曲面凹凸算法原理是用屏幕空间的导数ddx/ddy反推出世界空间的两个切向量再结合噪声在切向上的变化率把法线顶起来。它最大的好处是不需要模型的切线数据任何模型扔进来都能用省了一张法线图和一个顶点属性。代码里det要小心处理。当三个点共线或者法线与视线接近垂直的时候det有可能接近 0那时候sign(det)就会抖动模型表面会出现闪烁的暗斑。加一个abs(det) 1e-6的保护是很有必要的。我第一次做的时候没加在曲面模型上就出现了沿轮廓一圈的黑边。第三步光学计算。菲涅尔反射是冰面的灵魂公式就是pow(1 - saturate(dot(N, V)), _RimPower)。_RimPower越小边缘光越宽越大越窄越锐。我一般给 3 到 4。这个值给大了以后冰面看起来会像有一圈荧光管很假给小了就像整个模型蒙了一层雾。冰面的高光要比原本的材质锐得多——冰是光滑的而大多数模型贴图做的是漫反射。所以我用了单独的一个_Gloss默认 80和_SpecIntensity默认 0.9不跟原来的高光走。第四步环境光。这里有坑如果只算主光方向光冰面落在阴影里会直接变成纯黑看起来像被挖了一个洞。补上ShadeSH9(float4(N, 1))用球谐光照采样环境色之后就好多了。当然前提是场景里有烘焙过光照探头或者天空盒。如果是纯实时光照的场景可以用UNITY_LIGHTMODEL_AMBIENT兜底。第五步混合与折射。最终颜色是基础色和冰色的lerp混合系数就是iceMask。折射部分把抓屏坐标按扰动后的法线偏移一点再乘以一个不透明度_RefractTint叠上去。_Refract的值要特别克制0.02 到 0.05 已经能看出效果了给到 0.1 以上画面就整个扭曲了。3.4 参数怎么调几组可以直接抄的数值参数是死的场景是活的。我把我项目里常用的几组数值列出来方便直接抄效果风格结冰进度柔和度边缘光锐度凹凸折射膨胀轻度结霜0.2 - 0.40.182.00.0300中度结冰0.5 - 0.70.123.00.080.030.01完全冰冻0.85 - 1.00.104.00.120.050.02冰系技能命中0.90.065.00.150.060.03注意冰系技能命中那一行的柔和度给得比较小是为了让结冰看起来更急蔓延边缘更锐利。轻度结霜反而要把柔和度调大因为霜是慢慢渗过来的。驱动_FreezeAmount的 C# 脚本我也放一份关键是别每帧 new 材质实例using UnityEngine; public class FreezeController : MonoBehaviour { [SerializeField] Renderer targetRenderer; [SerializeField] float freezeDuration 0.8f; [SerializeField] AnimationCurve freezeCurve AnimationCurve.EaseInOut(0f, 0f, 1f, 1f); static readonly int FreezeAmountID Shader.PropertyToID(_FreezeAmount); MaterialPropertyBlock mpb; float timer -1f; void Awake() { mpb new MaterialPropertyBlock(); } public void Freeze() { timer 0f; } void Update() { if (timer 0f) return; timer Time.deltaTime; float t Mathf.Clamp01(timer / freezeDuration); targetRenderer.GetPropertyBlock(mpb); mpb.SetFloat(FreezeAmountID, freezeCurve.Evaluate(t)); targetRenderer.SetPropertyBlock(mpb); if (t 1f) timer -1f; } }用MaterialPropertyBlock的好处是不打断批处理也不产生材质实例几十个物体同时结冰也不会把内存撑起来。调试的时候记得用renderer.sharedMaterial不然你改的是运行时实例改完一保存参数又回去了这个坑我踩过不止一次。4. 移植到 URP改动点清单4.1 必须替换的宏和结构体URP 下的 Shader 和 Built-in 的差异其实没想象中大但有几个地方不改就是直接报错或者材质变粉。首先是 Tags。URP 必须加RenderPipelineUniversalPipelinePass 里的 LightMode 要从ForwardBase改成UniversalForward。少了这一步URP 的渲染管线会直接跳过这个 Pass模型要么不渲染要么用回退报错的粉色。然后是头文件。Built-in 里的UnityCG.cginc、Lighting.cginc、AutoLight.cginc全部换成 URP 的#include Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl #include Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl #include Packages/com.unity.render-pipelines.universal/ShaderLibrary/DeclareOpaqueTexture.hlsl顶点变换的宏也换了UnityObjectToClipPos变成TransformObjectToHClipUnityObjectToWorldNormal变成TransformObjectToWorldNormal。世界空间相机位置从_WorldSpaceCameraPos改成GetCameraPositionWS()这个在 VR 或者多相机场景下特别重要因为 URP 里_WorldSpaceCameraPos在某些渲染模式下会跟当前渲染的相机对不上。光照部分主光的_WorldSpaceLightPos0、_LightColor0这些内置变量统一用GetMainLight()拿到 Light 结构体里面的direction、color、shadowAttenuation都是现成的Light mainLight GetMainLight(); float3 L mainLight.direction; float3 lightCol mainLight.color; float atten mainLight.shadowAttenuation;阴影需要多编译几个关键字#pragma multi_compile _ _MAIN_LIGHT_SHADOWS _MAIN_LIGHT_SHADOWS_CASCADE #pragma multi_compile _ _SHADOWS_SOFT #pragma multi_compile_fragment _ _ADDITIONAL_LIGHTS如果漏了_SHADOWS_SOFTURP 里软阴影的采样就不对了边缘会出现很硬的方块锯齿——这个坑我在一个项目里排查了大半天最后发现就是一个关键字的锅。4.2 GrabPass 的替代方案URP 里没有 GrabPass。要拿场景颜色有两个办法。第一个是在 URP Asset 里把 Opaque Texture 打开然后用SampleSceneColorfloat3 sceneCol SampleSceneColor(screenUV).rgb;这个_CameraOpaqueTexture是管线在渲染完不透明物体之后统一生成的一份降分辨率场景图比 Built-in 的 GrabPass 便宜很多。缺点是分辨率可能不够可以在 URP Asset 里调分辨率倍率以及它不含半透明物体所以如果你后面还有一层半透明的冰雾那一层是不会被抓进去的。第二个是用 Renderer Feature 自己抓一张在特定时间点比如所有角色渲染完之后执行。这种方式更灵活可以控制抓取时机和分辨率代价是要写 C# 的 ScriptableRendererFeature代码量不小。如果冰冻效果只需要作用于特定几个物体我一般用第二种因为可控。用 Opaque Texture 的时候屏幕 UV 需要做一次从裁剪空间到 UV 空间的归一化ComputeScreenPos的结果不能直接当 UV 用得除以 w 分量。这个在 Built-in 版里写顺手了就很容易在移植时漏掉表现出来就是折射画面整体偏移。4.3 Shader Graph 版本怎么搭说实话这个效果我用 Shader Graph 搭过一版能用但不太舒服。原因是三平面映射和那套凹凸算法都需要导数和比较多的运算用节点拼出来会非常庞大一个 Sub Graph 能铺满两屏。不过如果团队里是美术主导Shader Graph 的优势还是明显的。我的搭法是这样把三平面采样封装成一个 Sub Graph输入是世界坐标、世界法线和采样密度输出一个 0 到 1 的噪声值内部用三次 Sample Texture 2D 加 Blend 节点拼。这个 Sub Graph 可以复用到其他材质里。结冰遮罩用 Smoothstep 节点A 和 B 分别接 Frost - Softness 和 Frost SoftnessT 接 Freeze Amount。边缘光用 Fresnel Effect 节点它的 Power 参数就是_RimPower。凹凸部分Shader Graph 有 DDX 和 DDY 节点可以复刻那套算法但如果只是想要霜花质感用一张 Normal Map 采样加扰动反而更省事节点少一半。Shader Graph 版本的坑主要在两个地方。一是参数暴露所有需要按材质实例调的参数都要设成 Exposed而且命名要跟程序那边约定好不然在代码里SetFloat的时候会拼错还不好查。二是变体Shader Graph 自动生成的变体数量比手写多不少在微信小游戏打包的时候这个会直接反映到包体和加载时间上需要检查 Shader Variant Collection把用不到的变体裁掉。我现在的做法是混合主体结构手写需要美术调的部分颜色、参数暴露出来一些可复用的功能块用 Shader Graph 的 Sub Graph 做好再导出成 HLSL 贴进来。两边的好处都占一点。5. 常见问题排查与性能优化5.1 常见问题速查表前面讲了很多原理但实际做的时候最容易卡住的是各种看起来不对的现象。我把这些年遇到的问题整理成一张表按现象查就行现象常见原因排查与解决结冰图案跟着相机跑用了屏幕空间或视空间坐标采样换成世界坐标或物体空间坐标物体移动时霜花流动用世界坐标采样改用物体空间坐标或冻结时记录局部坐标不同模型上霜花大小不一世界坐标采样随物体缩放变化用物体空间归一化坐标或按 Renderer 包围盒尺寸补偿边缘硬得像刀切_FrostSoftness太小提高到 0.08 以上没有冻住的过程感遮罩用了线性插值改用 smoothstep沿噪声等值线过渡冰面在阴影里死黑只算了主光补ShadeSH9或环境光高光位置偏法线没走逆转置矩阵用UnityObjectToWorldNormal表面出现闪烁黑斑det接近 0sign抖动给det加下限保护移动端出现色块条纹ddx/ddy在低精度下失效强制 float 精度或去掉凹凸抓屏折射错位膨胀写在抓屏坐标计算之后调整代码顺序URP 下材质变粉缺 RenderPipeline 标签或 LightMode补 Tags 和UniversalForwardURP 软阴影边缘是硬方块漏了_SHADOWS_SOFT关键字补上 multi_compile参数调了没反应改的是运行时实例化的材质调试用renderer.sharedMaterial结冰范围超出预期用了世界坐标相邻物体互相影响改用物体空间或用 Renderer bounds 限定这张表里前三条基本是坐标空间的问题中间几条是遮罩算法的问题后面几条是管线适配的问题。按这个分类去排查效率会高很多。展开说两个印象最深的。一个是霜花流动的问题。当时做了一个角色从冰面上走过的效果发现角色一移动身上的霜花图案就跟着滑动看起来像贴图在角色身上游泳。排查了很久才意识到是用了世界坐标采样。改成物体空间坐标后图案彻底焊死了。但这个改动又带来一个新问题如果角色在原地转圈物体空间坐标不动图案就完全静止看起来有点呆。后来的解决办法是加一层很轻微的世界坐标噪声做扰动两者结合既有冻住的稳定感又有细微的呼吸感。另一个是 URP 下的抓屏错位。把 Built-in 版本搬过去的时候用 Opaque Texture 拿到的画面整体偏了。查了半天最后发现是屏幕 UV 没有做归一化ComputeScreenPos的结果直接当 UV 用了少了除以 w 这一步。这个在 Built-in 版里因为tex2Dproj会自动处理所以一直没暴露出来。5.2 移动端和微信小游戏该砍哪里冰冻效果在 PC 上随便跑到了手机上就得精打细算。我按性价比排一下砍的顺序。第一刀砍折射。抓屏或者 Opaque Texture 在移动端是很大的一笔开销尤其在小游戏里很多设备在这种全屏纹理操作上会直接掉到二十帧出头。如果实在需要折射感用一张低分辨率的 cubemap 做伪折射把反射向量采样 cubemap再按法线扰动一点的 UV 采第二次两次采样叠加成本比场景抓取低很多视觉上能糊弄过去。第二刀砍三平面映射。从三次采样降到两次去掉权重最小的那个平面或者干脆在移动端做成 UV 映射版本。这个可以通过 shader variant 来切换也可以拆成两个 shader。注意去掉一个平面的采样以后权重需要重新归一化否则整体会变暗。第三刀砍凹凸。那套基于导数的算法要额外采两次噪声在移动端有点奢侈。如果模型本来就带切线数据用一张法线贴图反而更便宜——一次采样就搞定。只有在模型没有切线比如程序化生成的网格、扫描重建的模型时才值得用这套算法。然后是一些通用优化颜色相关的变量用 half坐标相关的用 float用MaterialPropertyBlock而不是每帧 new 材质实例来改_FreezeAmount如果冰冻只作用于少量物体可以用 Renderer 的包围盒先做一次粗筛把不在这范围内的整个跳过尤其在大场景里这个操作能省不少 draw call这跟 unity 游戏优化里限定数据块大小的思路是一回事——先把明显的无效计算挡在门外再谈算法层面的优化。微信小游戏打包还有个专门的注意点shader 变体会被全部打进包里所以#pragma multi_compile后面跟的关键字要精简。像_ADDITIONAL_LIGHTS这种如果项目里没有额外光就#pragma skip_variants _ADDITIONAL_LIGHTS直接跳掉。我见过一个项目光 shader 变体就占了十几兆的首包一查就是几个和特效相关的 shader 带了一堆用不到的关键字。还有个小技巧如果场景里冰冻的物体很多但都长得差不多可以把它们的材质统一用 GPU Instancing 合并。这样_FreezeAmount就没法逐个物体传不同值了。解决办法是用顶点色或者 UV2 通道把每个实例的结冰进度传进去这样既保持了合批又能让每个物体冻住的时机不一样。做数字孪生里的极端天气演示时我用这招让一整个园区的建筑分批冻住draw call 只有个位数。5.3 几个我踩过之后觉得值钱的细节噪声图的制作。我一般是自己烘一张 512x512 的灰度图用 C# 的Mathf.PerlinNoise叠三层一层大尺度频率 2做整体形状一层中尺度频率 8做霜花的主体一层小尺度频率 32做细碎的颗粒。三层按 0.5、0.35、0.15 的权重叠加再拉一下对比度比随便找一张噪声图效果好很多。如果嫌采样多可以把主体噪声放 R 通道细节噪声放 A 通道一次采样拿两个数据。贴图的导入设置。当遮罩用的噪声图sRGB 要关掉因为我们是拿它当数值用的不是颜色Wrap Mode 设 Repeatmipmap 可以开但三平面映射在远处会因为 mip 级别不一致出现接缝可以把 mip bias 调一下或者干脆关掉 mip 用各向异性过滤补。关于拼接的连贯性。如果场景里相邻的两个物体都用了这个材质因为采样的是世界坐标交界处会自然连续看起来就像一个整体冻住了——这是三平面映射的一个额外好处。但如果用的是物体空间坐标就没有这个效果得手动对齐。做整体场景冰冻的时候这一点很关键。从某个点扩散的结冰。很多人做结冰喜欢加一个冰晶从中心向外扩散的动画其实不用额外做几何只要在遮罩计算里加上到某个世界坐标点的距离衰减就行float dist distance(i.worldPos, _FreezeOrigin.xyz); float drive saturate(_FreezeAmount * 2.0 - dist * _SpreadSpeed);这个做法特别适合技能特效比如技能命中点开始结冰非常直观而且比改_FrostTiling可靠得多——改密度会让整个纹理都在缩放看起来像是物体在呼吸。别忘了烘焙光照。如果物体上有光照贴图结冰之后冰面是亮的基础色却是一张烘焙过的暗贴图两个混在一起会很奇怪。我的做法是给冰层部分单独算一份光照不走烘焙贴图或者把光照贴图作为环境光的一部分叠上去。这个细节在写实项目里会被美术一眼看出来。行了基本就是这些。冰冻这个效果看着简单真要做得耐看遮罩那一层的功夫得占七成以上。我折腾到现在最深的体会是别急着调颜色和高光先把噪声遮罩的形态磨到满意剩下的都是水到渠成的事。