Unity冰冻Shader实战:三平面遮罩、HLSL与移动端优化
2026/9/18 7:31:14 网站建设 项目流程

冰冻效果算是 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),好了一些,但还是有点"均匀",因为噪声图本身的分布是固定的,线性切割出来的是等距的条纹。

真正让我满意的是smoothstep

float iceMask = smoothstep(frost - _Softness, frost + _Softness, _FreezeAmount);

这里smoothstep(a, b, x)的意思是:x小于a时返回 0,大于b时返回 1,中间用三次平滑曲线过渡。把ab设成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 冰冻 Shader

3.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 { "RenderType"="Opaque" "Queue"="Geometry" } GrabPass { "_IceGrab" } Pass { Tags { "LightMode"="ForwardBase" } 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 上插值器超限就直接编译失败。如果项目要覆盖低端机,最好把worldNormalworldPos合并一下,或者把视角方向丢掉,改用_WorldSpaceCameraPos - worldPos在片元里现算。后者的代价是每个像素多一次减法,但能省一个插值器,挺划算的。

法线的空间转换也值得说一句。UnityObjectToWorldNormalmul(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 必须加"RenderPipeline"="UniversalPipeline",Pass 里的 LightMode 要从ForwardBase改成UniversalForward。少了这一步,URP 的渲染管线会直接跳过这个 Pass,模型要么不渲染,要么用回退报错的粉色。

然后是头文件。Built-in 里的UnityCG.cgincLighting.cgincAutoLight.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 结构体,里面的directioncolorshadowAttenuation都是现成的:

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_SOFT,URP 里软阴影的采样就不对了,边缘会出现很硬的方块锯齿——这个坑我在一个项目里排查了大半天,最后发现就是一个关键字的锅。

4.2 GrabPass 的替代方案

URP 里没有 GrabPass。要拿场景颜色,有两个办法。

第一个是在 URP Asset 里把 Opaque Texture 打开,然后用SampleSceneColor

float3 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 + Softness,T 接 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接近 0,sign抖动det加下限保护
移动端出现色块条纹ddx/ddy在低精度下失效强制 float 精度,或去掉凹凸
抓屏折射错位膨胀写在抓屏坐标计算之后调整代码顺序
URP 下材质变粉缺 RenderPipeline 标签或 LightMode补 Tags 和UniversalForward
URP 软阴影边缘是硬方块漏了_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 设 Repeat;mipmap 可以开,但三平面映射在远处会因为 mip 级别不一致出现接缝,可以把 mip bias 调一下,或者干脆关掉 mip 用各向异性过滤补。

关于拼接的连贯性。如果场景里相邻的两个物体都用了这个材质,因为采样的是世界坐标,交界处会自然连续,看起来就像一个整体冻住了——这是三平面映射的一个额外好处。但如果用的是物体空间坐标,就没有这个效果,得手动对齐。做整体场景冰冻的时候,这一点很关键。

从某个点扩散的结冰。很多人做结冰喜欢加一个"冰晶从中心向外扩散"的动画,其实不用额外做几何,只要在遮罩计算里加上到某个世界坐标点的距离衰减就行:

float dist = distance(i.worldPos, _FreezeOrigin.xyz); float drive = saturate(_FreezeAmount * 2.0 - dist * _SpreadSpeed);

这个做法特别适合技能特效,比如技能命中点开始结冰,非常直观,而且比改_FrostTiling可靠得多——改密度会让整个纹理都在缩放,看起来像是物体在"呼吸"。

别忘了烘焙光照。如果物体上有光照贴图,结冰之后冰面是亮的,基础色却是一张烘焙过的暗贴图,两个混在一起会很奇怪。我的做法是给冰层部分单独算一份光照,不走烘焙贴图,或者把光照贴图作为环境光的一部分叠上去。这个细节在写实项目里会被美术一眼看出来。

行了,基本就是这些。冰冻这个效果看着简单,真要做得耐看,遮罩那一层的功夫得占七成以上。我折腾到现在最深的体会是:别急着调颜色和高光,先把噪声遮罩的形态磨到满意,剩下的都是水到渠成的事。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询