资讯详情

Unity Shader冰冻效果实现:原理、代码与优化

📅 2026/9/18 18:21:08 | 华诺云谱 👁 阅读
Unity Shader冰冻效果实现:原理、代码与优化
做冰系技能或者被冰冻住的机关时绕不开的一件事就是 unity shader 实现冰冻效果。我第一次接手这类需求的时候脑子里想的很简单——贴一张冰的贴图调个蓝色加点透明不就行了。结果做完给主美看对方只说了句像个蓝色塑料袋当场把我打回重做。后来折腾了大概三四周翻了大量的参考资料拆过别人做好的冰系特效才慢慢把这件事的门道理清楚冰冻效果本质上不是一个材质而是三层视觉叠加再加上一层随时间流动的传播逻辑任何一层缺失观感都会塌掉。这篇内容我会把 unity shader 实现冰冻效果的完整思路和可复现的代码都摊开讲包括 Built-in 管线下手写 CG 的完整 Shader、URP 管线下的迁移方案、Shader Graph 的节点连线思路还有我在移动端和 XR 设备上踩过的性能坑。不管你是刚学 Shader 一两个月的新人还是已经写过一些自定义材质想补全知识盲区的老手都能直接抄作业改改参数就能用在自己的项目里。代码我会给全参数我会解释为什么这么取翻车点我会明确标出来。1. 冰冻效果的视觉拆解与实现路线选型在做任何 Shader 之前我都会先做一件事把这个效果拆成几层。因为直接写代码很容易变成参数大乱斗改到最后自己都不知道哪个参数在控制什么。冰冻效果拆下来其实是三件事物体表面结了一层霜、霜下面的本体开始半透明并发生光学折射、边缘因为光线掠过而发白发亮。这三层的权重关系决定了你做的冰是薄薄一层霜还是整块厚冰。1.1 三层视觉霜化、折射与边缘光第一层是表面霜化。冰不是均匀覆盖的它有一个不规则的推进边界边界前方是原来的材质边界后方是覆盖上去的霜白。这个边界一定要有噪声扰动如果你用一个笔直的水平面切过去看起来就像是给模型刷了个渐变非常廉价。霜化层的颜色偏冷、明度高、饱和度低白里带一点青通常在 RGB 上大约落在 (0.85, 0.95, 1.0) 这个区间。第二层是内部折射。这是让冰看起来有厚度的关键。真正的冰会扭曲它背后的画面所以我们要在屏幕空间抓取一张背景图然后根据冰面的法线去偏移采样坐标。偏移量不能大通常屏幕 UV 上偏 0.02 到 0.05 就够了超过 0.1 整个画面就会像被打了马赛克一样糊成一片。这一层最能体现冰的质感也最吃性能后面的选型讨论会重点讲这个。第三层是边缘光Fresnel。光线以掠射角打在物体边缘时反射强度会急剧上升这是物理规律。冰的折射率高边缘发亮非常明显所以我们要用菲涅尔公式把边缘提亮同时叠加一层锐利的高光。这三层叠加的顺序是有讲究的我一般是先算折射底再叠霜色最后加边缘光和高光顺序错了颜色会脏。1.2 路线选型抓屏、替代方案与假折射折射这一层有三种做法选哪个直接决定你的性能预算和平台兼容性。第一种是GrabPassBuilt-in 管线的老办法语法就一行GrabPass { _IceGrabTex }。它能拿到当前帧渲染到这一刻的屏幕内容直接用来采样。优点是写起来最省事缺点是每次抓屏都是一次显存拷贝在移动端上可能是几毫秒的开销。而且它在 URP 里根本不能用如果你项目用的是 URP这条路直接堵死。第二种是 URP 的Camera Opaque Texture。需要在 URP Asset 里勾选 Opaque Texture 选项然后在 Shader 里采样_CameraOpaqueTexture。它只有不透明物体透明物体不在里面但做冰的折射足够了。这个方案比 GrabPass 高效因为它不是每个物体都抓一次而是整个相机抓一次所有材质共享。代价是它只包含不透明层如果冰背后还有别的半透明特效就不会出现在折射里。第三种是假折射完全不采样屏幕直接用视差偏移Parallax Offset去采样物体自己的贴图。性能最好全平台通吃尤其适合移动端和 XR 一体机。代价是它骗不过眼睛——背后没有真实物体时看不出来一旦冰前面有动态的东西穿帮就很明显。我的经验是手机端小游戏、VR 一体机上的远景冰块用假折射完全够用PC 端或者主机端的主角技能还是老老实实用真实抓屏。方案性能开销平台兼容适用场景GrabPass高逐物体抓屏仅 Built-inPC/主机冰体数量少Camera Opaque Texture中每帧一次URP 全平台URP 项目通用选择假折射视差低全平台移动端、XR、远景物体选型的时候还有一个容易被忽略的点冰体在屏幕上占多大面积。如果一个冰块的屏幕占比很小Ping 几像素那折射再精确也没人看得出来直接上假折射。反过来如果是主角被冻住的特写镜头冰占满半个屏幕那这上面花多少性能都值。做效果要跟着镜头走不能平均用力。2. 冰感的核心算法菲涅尔、法线扰动与冻结传播技术选型定下来之后接下来要解决的是怎么算。这一章讲三个核心算法的原理和参数取值不理解原理的话你后面调参数会完全是盲调改了这个动了那个最后只能靠运气。2.1 冻结遮罩效果的核心骨架整个冰冻 Shader 里最重要的一个变量不是颜色也不是法线而是冻结遮罩Freeze Mask。它是一个 0 到 1 的值0 表示这个像素还是原来的材质1 表示完全冻结中间值就是过渡带。所有其他效果——霜色、折射、边缘光、顶点膨胀——都要乘以这个遮罩才会生效。所以遮罩设计得好不好直接决定效果像不像样。遮罩的生成方式通常有两种。一种是基于空间的渐变比如从物体底部的世界坐标 Y 值往上推或者从受击点的距离往外推。这种做法实现简单用saturate((worldPos.y - startY) / range)就能算出来但它的问题是边界太规整看起来像被水平刀切过。另一种是渐变加噪声扰动这也是我实际项目里用的方案。先算出一个基础梯度再采一张噪声贴图的 R 通道用噪声去扰动梯度的阈值。具体写法是这样的基础梯度算出gradient噪声采样得到noise然后mask saturate((gradient - threshold) / softness noise * amp - offset)。这里的softness控制过渡带的宽度取值 0.05 到 0.2 之间比较自然太小会出现硬边太大整个效果会糊成一片雾。amp一般取 1.0 到 1.5让噪声有足够的扰动强度去打破直线边界。注意噪声贴图一定要做成无缝平铺的Seamless Tiling否则在 UV 重复的时候会看到明显的方格接缝这个坑我在第一个项目里踩了整整两天一直以为是 Shader 算错了。还有第三种也是我最推荐的做法美术在建模时刷顶点色。让美术在 DCC 软件里用顶点色把一个通道比如 Alpha 通道刷成冻结权重从受击点或者底部往上衰减。这样你连噪声都不用算边界完全由美术控制效果最可控、性能也最好因为顶点色是顶点插值出来的几乎零开销。缺点是每次调整形状都要回 DCC 重刷迭代速度慢。我的建议是先做好 UV 加噪声的通用方案等项目后期定稿之后再让美术刷顶点色做最终版本。2.2 菲涅尔为什么冰块边缘一定得亮菲涅尔效应的物理背景是这样的当光线从一种介质射入另一种介质时入射角越接近掠射角也就是视线越平行于表面反射的比例就越高。你可以想象站在湖边看水面——低头看脚下的水是透明的能看见水底的石子但抬头看远处的水面看到的是天空的倒影完全看不见水下。冰的折射率大约是 1.31比水1.33略低但比空气1.0高得多所以边缘发亮的特征非常明显。在 Shader 里的近似公式是pow(1.0 - saturate(dot(N, V)), power)。这里的 N 是表面法线V 是从表面指向摄像机的视线方向两者点积得到的是一个 0 到 1 之间的值正对摄像机时接近 1边缘时接近 0。所以1 - dot(N,V)就在边缘处接近 1取幂次之后会进一步压暗中间区域、突出边缘形成明显的轮廓光。关于power参数的取值这里有个换算关系值得说清楚。power越大边缘光越窄越锐利越小光带越宽越柔和。我实测下来的经验值是power 2的时候光是宽而散的适合表现整块冰都在发光power 4到5是比较平衡的轮廓感power超过6之后就只剩一条极细的边看起来像描了个线框反而假了。绝大多数情况下我会用 3 到 4 之间。强度系数_FresnelIntensity我一般取 1.0 到 2.0。这里要注意一个常见的错误直接color fresnel会把颜色加爆因为白霜的颜色本来就接近 1.0再加就超过 1 变成死白。正确的做法是用lerp混合——color lerp(color, frostColor, saturate(fresnel * intensity))这样最亮也就是霜色本身不会过曝。2.3 法线扰动与折射偏移的尺度换算折射的实现是在屏幕空间偏移采样坐标。具体做法是先把切线空间的法线贴图采样出来解包后转到世界空间然后取worldNormal.xy乘上偏移强度加到屏幕 UV 上。这里有几个容易搞错的地方。第一个是坐标空间。如果你直接用切线空间的法线去做偏移冰块不同朝向的面偏移方向会不一致看起来会很别扭。一定要转到世界空间或者视图空间再用。世界空间的做法是构造 TBN 矩阵——float3 worldNormal normalize(tangent * n.x binormal * n.y normal * n.z)其中binormal cross(normal, tangent) * tangent.w。这个tangent.w是副切线的方向符号忘了乘它会导致法线贴图在部分模型上反向。第二个是偏移量的单位。屏幕空间的 UV 范围是 0 到 1所以_RefractStrength取 0.03 就意味着在 1080P 下偏移大约 32 个像素。这个量级已经相当明显了。我在移动端上一般取 0.015 到 0.025PC 端可以到 0.04。有一个换算的小技巧如果你希望偏移量在视觉上恒定不管分辨率多高看起来都差不多可以乘上_ScreenParams.xy的倒数做补偿但说实话在实际项目中我很少这么做因为大部分游戏的分辨率区间是固定的直接调一个固定值最省事。第三个是偏移只在冻结区域生效。如果不乘冻结遮罩那没冻住的部分也会跟着扭曲看起来像是模型在水里泡着。这个细节很多人会忘我在评审别人的 Shader 时见过好几次。提示法线扰动的强度应该和冻结进度挂钩。刚开始结冰的时候扰动弱一点完全冻住之后扰动加强这样有冰层在增厚的感觉。用lerp(0.2, 1.0, freezeMask)去缩放_NormalScale就能实现。2.4 顶点膨胀让冰有厚度还有一个容易被忽略但效果提升很大的细节顶点沿法线外扩。真实的冰在物体外面结起来是会增加体积的哪怕只有一两厘米视觉上也能明显感觉到变厚了。实现只有一行v.vertex.xyz v.normal * _Expand * freezeAmount。这里的_Expand我一般取 0.01 到 0.05 的物体本地空间单位具体要看模型的尺度。如果是角色模型Unity 单位下身高约 1.80.02 左右比较自然如果是场景道具可能需要 0.05。这个外扩必须乘以冻结遮罩而且最好只对已经完全冻住的区域生效否则过渡带会抖动。还有一个更讲究的做法在冻结边界处做冰刺效果——用噪声把外扩量随机放大形成参差不齐的尖角。这个在《冰汽时代》那种风格里很出效果但实现复杂度会上升不少属于进阶玩法。外扩带来一个副作用必须提醒它会让模型的包围盒变大。如果物体的碰撞体没有同步调整就会出现视觉上已经冻住膨胀了、但攻击判定还是原尺寸的情况。在 Unity 里MeshRenderer 的包围盒是自动计算的但如果是自己维护的碰撞体需要手动扩一点。3. Built-in 管线手写 Shader 全流程实录前面讲的是原理这一章直接上代码。我以 Built-in 管线的 GrabPass 方案为例因为它的代码结构最完整把这段吃透之后迁移到 URP 只是换几个采样函数的事。3.1 属性块与渲染状态ZWrite 为什么必须关先看属性块。属性分成四组基础外观主贴图、基础色、冰层细节法线贴图、裂纹贴图、噪声贴图、冻结控制冻结进度、边界柔和度、光学效果菲涅尔、折射强度、高光。分组写属性不是为了好看而是为了让美术在材质面板上一眼找到自己要调的参数按功能分组能大幅降低沟通成本。渲染状态里最关键的一条是ZWrite Off。为什么因为冰是半透明的如果写入深度缓冲那么后渲染的透明物体在通过深度测试时会被前一个透明物体挡住导致后面的东西看不见。这就好比两块玻璃叠在一起如果前面的玻璃在深度图上占坑了后面的玻璃就会因为深度测试失败而被剔除掉但实际上一块玻璃在另一块后面应该能透过它被看到。所以所有半透明材质都要关掉深度写入。但是关掉 ZWrite 会带来排序问题半透明物体之间的前后关系只能靠渲染队列和物体中心点距离来排没有逐像素的深度判断。这在单个冰块上通常没问题但如果你有一堆冰粒叠在一起就可能出现闪烁。解决办法有三个把渲染队列从一个统一的 Transparent 拆成几个比如 3000、3010、3020手动区分层次或者对特别重要的物体改用ZWrite On加 Alpha Test或者干脆用不透明的冰——很多游戏为了稳定性冰块其实是不透明的只是颜色偏浅加了点边缘光。Blend SrcAlpha OneMinusSrcAlpha是标准的 Alpha 混合源颜色乘以源 Alpha 加上目标颜色乘以1 减源 Alpha。Cull Back剔除背面因为我们只需要看到朝向摄像机的那一面。Lighting Off表示关闭内置光照计算我们自己在片元里算高光。这些设置配合Queue Transparent和RenderType Transparent标签使用。3.2 顶点阶段梯度、膨胀与 TBN 传递顶点着色器负责四件事。第一件事是算冻结梯度。我用的是本地空间的顶点 Y 坐标因为本地空间不会受物体旋转影响如果物体在场景里转了个方向用世界空间 Y 会导致冻结方向跟着歪掉。代码是float gradient saturate(1.0 - (v.vertex.y 0.5))这里假设模型的本地 Y 范围大约在 -0.5 到 0.5 之间Unity 的默认立方体和多数导出模型都是这个范围。如果你的模型不是这个尺度这个系数就要改或者更通用的做法是传一个_HeightMin和_HeightMax参数进来做归一化。第二件事是顶点膨胀前面讲过v.vertex.xyz v.normal * _Expand * _FreezeAmount * gradient。注意这里乘了gradient而不是最终的freezeMask因为在顶点阶段我们拿不到片元阶段的噪声遮罩。这是一个折中——顶点阶段只能用梯度做近似如果想要更精确的膨胀边界需要用顶点色或者更复杂的计算。第三件事是计算屏幕坐标。o.grabUV ComputeGrabScreenPos(o.pos)这个函数会处理好投影和平台差异比如某些平台的 Y 轴翻转返回一个齐次坐标。在片元里除以其 w 分量就得到了屏幕 UV。如果不用这个函数自己算ComputeScreenPos在部分平台上会出现上下颠倒的问题。第四件事是传递 TBN 基向量。把世界空间的法线、切线、副切线作为插值变量传给片元这样片元里才能把法线贴图从切线空间转到世界空间。这里副切线要乘v.tangent.w前面提过漏了会出问题。3.3 片元阶段逐段拆解计算过程片元着色器是整个效果的核心我按计算顺序拆开讲。第一步采样噪声并生成冻结遮罩。噪声 UV 是i.uv * _NoiseScale _Time.y * _NoiseSpeed.xy乘时间是为了让噪声缓慢流动营造冰霜在蔓延的动态感。速度别调快_NoiseSpeed取 (0.02, 0.03) 足够了调快了像是在放动画。然后freezeMask saturate((gradient - threshold) / softness noise * 1.2 - 0.2)这个公式里的- 0.2是偏移量用来把噪声的均值往下压一点让噪声的分布更偏向未冻结这样冻结的边界会自然一些。这几个数字不是随便写的你可以理解为经验参数上下浮动 20% 都还能用。第二步处理法线。采样法线贴图解包然后用_NormalScale * lerp(0.2, 1.0, freezeMask)缩放 XY 分量。未冻结的地方法线扰动保留 20%让原有材质还有一点正常的凹凸感冻结之后扰动全开冰面的坑洼感才会出来。然后用 TBN 转到世界空间并归一化。第三步算折射偏移。float2 offset worldNormal.xy * _RefractStrength * freezeMask然后grabUV i.grabUV.xy / i.grabUV.w offset。这里有个细节偏移方向和大小都来自法线所以冰面凹凸越强折射扭曲越明显这是符合物理直觉的。然后采样抓屏纹理得到背景色。第四步混合基础色和折射背景。这里我用的是两层 lerp。第一层是lerp(albedo, sceneColor, freezeMask * 0.8)让冻结区域显示背景第二层是lerp(color, sceneColor * _BaseColor.rgb, freezeMask * 0.5)让背景带上冰的颜色。为什么要两层因为只做一层的话冰要么太透明看不见本体要么太实看不见背景。两层混合能同时保留冰的颜色和背后的画面这两个信息。第五步叠加菲涅尔霜色和高光。菲涅尔用前面讲过的公式算完之后color lerp(color, _FrostColor.rgb, frost)。高光用 Blinn-Phong 近似halfDir normalize(lightDir viewDir)然后pow(saturate(dot(worldNormal, halfDir)), _SpecPower) * _SpecIntensity。这里的_SpecPower我取 64冰面光滑高光要锐。如果是冰霜覆盖的粗糙表面可以降到 16 到 32。第六步叠加裂纹发光。裂纹贴图的 R 通道作为发光遮罩乘上霜色和一个较小的系数0.3 到 0.4加进去。这一步是锦上添花能让冰看起来内部有应力纹路。裂纹贴图可以用噪点做边缘检测生成或者美术手绘一张。第七步计算最终透明度。alpha _Alpha * freezeMask再和菲涅尔光带做个 maxalpha max(alpha, frost * 0.9)。为什么要 max因为冰的边缘霜化很重应该是接近不透明的如果只按冻结遮罩算透明度边缘会透得过分看起来像玻璃纸。这个处理能让边缘实、中间透层次感会明显好很多。3.4 完整代码与材质配置下面是我实际项目里用的完整版本可以直接建个.shader文件贴进去Built-in 管线。Shader Custom/IceFreeze_Grab { Properties { [Header(Base)] _MainTex (Base (RGB), 2D) white {} _BaseColor (Base Tint, Color) (0.55, 0.8, 1.0, 1.0) [Header(Ice Detail)] _NormalMap (Ice Normal, 2D) bump {} _NormalScale (Normal Scale, Range(0, 3)) 0.8 _CrackTex (Crack Mask (R), 2D) black {} _NoiseTex (Noise (R), 2D) gray {} _NoiseScale (Noise Tiling, Float) 3.0 _NoiseSpeed (Noise Speed (XY), Vector) (0.02, 0.03, 0, 0) [Header(Freeze Control)] _FreezeAmount (Freeze Amount, Range(0, 1)) 0.5 _EdgeSoftness (Edge Softness, Range(0.01, 0.6)) 0.15 _Expand (Vertex Expand, Range(0, 0.6)) 0.02 [Header(Optics)] _FrostColor (Frost Color, Color) (0.85, 0.95, 1.0, 1.0) _FresnelPower (Fresnel Power, Range(0.5, 8)) 3.5 _FresnelIntensity (Fresnel Intensity, Range(0, 4)) 1.4 _RefractStrength (Refract Strength, Range(0, 0.3)) 0.035 _SpecPower (Spec Power, Range(1, 128)) 64 _SpecIntensity (Spec Intensity, Range(0, 4)) 1.1 _Alpha (Global Alpha, Range(0, 1)) 0.9 } SubShader { Tags { Queue Transparent RenderType Transparent IgnoreProjector True } GrabPass { _IceGrabTex } Pass { Name ICE_FORWARD Blend SrcAlpha OneMinusSrcAlpha ZWrite Off Cull Back Lighting Off CGPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc sampler2D _MainTex; float4 _MainTex_ST; sampler2D _NormalMap; float4 _NormalMap_ST; sampler2D _CrackTex; sampler2D _NoiseTex; sampler2D _IceGrabTex; fixed4 _BaseColor; fixed4 _FrostColor; float _NormalScale; float _NoiseScale; float4 _NoiseSpeed; float _FreezeAmount; float _EdgeSoftness; float _Expand; float _FresnelPower; float _FresnelIntensity; float _RefractStrength; float _SpecPower; float _SpecIntensity; float _Alpha; struct appdata { float4 vertex : POSITION; float3 normal : NORMAL; float4 tangent : TANGENT; float2 uv : TEXCOORD0; }; struct v2f { float4 pos : SV_POSITION; float2 uv : TEXCOORD0; float4 grabUV : TEXCOORD1; float3 wNormal : TEXCOORD2; float3 wTangent : TEXCOORD3; float3 wBinormal : TEXCOORD4; float3 wPos : TEXCOORD5; float gradient : TEXCOORD6; }; v2f vert (appdata v) { v2f o; o.uv TRANSFORM_TEX(v.uv, _MainTex); // 本地空间 Y 归一化梯度从下往上冻 float gradient saturate(1.0 - (v.vertex.y 0.5)); o.gradient gradient; // 顶点沿法线外扩模拟冰层厚度 v.vertex.xyz v.normal * _Expand * _FreezeAmount * gradient; o.pos UnityObjectToClipPos(v.vertex); o.grabUV ComputeGrabScreenPos(o.pos); float3 wN UnityObjectToWorldNormal(v.normal); float3 wT UnityObjectToWorldDir(v.tangent.xyz); float3 wB cross(wN, wT) * v.tangent.w; o.wNormal wN; o.wTangent wT; o.wBinormal wB; o.wPos mul(unity_ObjectToWorld, v.vertex).xyz; return o; } fixed4 frag (v2f i) : SV_Target { // 噪声扰动冻结边界 float2 noiseUV i.uv * _NoiseScale _Time.y * _NoiseSpeed.xy; float noise tex2D(_NoiseTex, noiseUV).r; float thresh 1.0 - _FreezeAmount; float mask saturate((i.gradient - thresh) / max(_EdgeSoftness, 0.001) noise * 1.2 - 0.2); // 法线切线空间 - 世界空间 float3 n UnpackNormal(tex2D(_NormalMap, i.uv)); n.xy * _NormalScale * lerp(0.2, 1.0, mask); float3 wN normalize(i.wTangent * n.x i.wBinormal * n.y i.wNormal * n.z); float3 viewDir normalize(_WorldSpaceCameraPos - i.wPos); // 折射偏移 float2 grabUV i.grabUV.xy / i.grabUV.w; grabUV wN.xy * _RefractStrength * mask; float3 sceneColor tex2D(_IceGrabTex, grabUV).rgb; // 底色与折射背景混合 float3 albedo tex2D(_MainTex, i.uv).rgb * _BaseColor.rgb; float3 color lerp(albedo, sceneColor, saturate(mask * 0.8)); color lerp(color, sceneColor * _BaseColor.rgb, mask * 0.5); // 菲涅尔霜化 float fres pow(1.0 - saturate(dot(wN, viewDir)), _FresnelPower); float frost saturate(fres * _FresnelIntensity) * mask; color lerp(color, _FrostColor.rgb, frost); // 冰面高光 float3 lightDir normalize(_WorldSpaceLightPos0.xyz); float3 halfDir normalize(lightDir viewDir); float spec pow(saturate(dot(wN, halfDir)), _SpecPower) * _SpecIntensity; color spec * mask * _FrostColor.rgb; // 裂纹发光 float crack tex2D(_CrackTex, i.uv).r; color crack * mask * _FrostColor.rgb * 0.35; // 透明度 float alpha saturate(max(_Alpha * mask, frost * 0.9)); return fixed4(color, alpha); } ENDCG } } FallBack Transparent/Diffuse }配套的 C# 控制脚本用协程平滑推进冻结进度using System.Collections; using UnityEngine; public class FreezeController : MonoBehaviour { [SerializeField] private Renderer targetRenderer; [SerializeField] private float freezeDuration 1.2f; [SerializeField] private AnimationCurve easeCurve AnimationCurve.EaseInOut(0, 0, 1, 1); private MaterialPropertyBlock mpb; private static readonly int FreezeId Shader.PropertyToID(_FreezeAmount); private void Awake() { mpb new MaterialPropertyBlock(); Apply(0f); } public void PlayFreeze() StartCoroutine(Animate(0f, 1f)); public void PlayThaw() StartCoroutine(Animate(1f, 0f)); private IEnumerator Animate(float from, float to) { float t 0f; while (t freezeDuration) { t Time.deltaTime; float k easeCurve.Evaluate(Mathf.Clamp01(t / freezeDuration)); Apply(Mathf.Lerp(from, to, k)); yield return null; } Apply(to); } private void Apply(float value) { if (targetRenderer null) return; targetRenderer.GetPropertyBlock(mpb); mpb.SetFloat(FreezeId, value); targetRenderer.SetPropertyBlock(mpb); } }这里我用MaterialPropertyBlock而不是直接改material.SetFloat原因是后者会导致材质实例化——每次调用都会生成一份新的材质副本如果有几十个敌人都被冻住材质数量会爆掉DrawCall 也会失控。用 MPB 就只传递参数不改材质。但有个坑要提醒MPB 会打断 URP 的 SRP Batcher。如果你的项目在 URP 下、对合批要求很高那就要权衡了——要么接受多几个 DrawCall 用 MPB要么用一个共享材质加 GPU Instancing 走另一套参数传递方式。这个没有标准答案看项目的瓶颈在哪。材质面板上的配置建议主贴图放原模型的贴图冰法线贴图用一张细密的颗粒噪点噪声贴图用无缝的 Perlin 噪声裂纹贴图没有也可以留黑。_Expand从 0.01 开始试着加加到看起来厚了一点但不夸张为止。4. URP 与 Shader Graph 的等价实现现在越来越多的项目直接上 URPGrabPass 那套彻底失效。这一章讲迁移方案和可视化工具的实现思路。4.1 Built-in 迁移到 URP 的差异清单迁移的时候有几处必须改。首先是抓屏方式删掉GrabPass改成在 URP Asset 里勾上 Opaque Texture然后在 Shader 里用_CameraOpaqueTexture。注意这个纹理的采样坐标和 GrabPass 的不太一样需要自己用ComputeScreenPos算出屏幕坐标再除以 w。而且它在 XR 单通道立体渲染下是双目拼合的采样时 UV 需要特殊处理最稳的做法是用 Shader Graph 里封装好的 Scene Color 节点它内部会调用SampleSceneColor方法处理 XR 的 UV 重映射。其次是光照的引用方式Built-in 里的_WorldSpaceLightPos0在 URP 里还有但如果是多光源场景或者自定义光照就得改成 URP 的光照宏。如果不想接 URP 的光照系统可以干脆不写光照、只用一个固定的方向向量做高光简单可控。第三是标签和 Include 文件URP 的#include Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl是必须的然后用HLSLPROGRAM而不是CGPROGRAM。这些细节改动虽然机械但漏了任何一个都会直接报编译错误或者渲染全黑建议迁移的时候对照官方文档的 URP shader 模板一点点改。4.2 Shader Graph 节点连线思路用 Shader Graph 做同样的效果节点连线可以按下面的思路走。冻结遮罩部分UV 节点接 Tiling And OffsetTime 节点接一个 Vector2 乘法后累加到 Offset 上然后接 Sample Texture 2D 采噪声输出接 Multiply乘 1.2再接 Add加 Position 节点的 Y 分量做梯度最后接一个 Smoothstep 或者 Remap 做钳制。这里的 Position 节点要选 Object 空间才能做出不受物体旋转影响的竖直梯度。折射部分Sample Scene Color 节点UV 接一个 Add 节点把屏幕坐标和法线偏移加起来。法线偏移从 Sample Texture 2D法线贴图类型设为 Normal出来接 Normal Vector 节点转到 World 空间然后 Split 取出 XY 分量乘 Strength 再乘冻结遮罩得到偏移量。混合和边缘光部分Fresnel Effect 节点直接给你算好菲涅尔输入 Normal 和 View Direction调整 Power 和 Intensity 即可比自己写公式方便。然后用 Lerp 节点做颜色混合最后连到 Master 节点的 Base Color、Alpha、Normal如果法线要影响光照上。Shader Graph 的好处是可视化、方便美术自己改缺点是复杂效果容易变成意面连线节点一多就难以维护而且生成出来的代码不一定比手写的优化。我的做法是如果是给美术长期迭代的效果用 Shader Graph如果是一次性确定、性能敏感的手写。4.3 XR 与移动端的适配经验在移动端和 XR 一体机上做冰冻效果有几个硬约束。第一个是填充率。移动 GPU 的填充率远低于桌面而透明加折射的 Shader 是典型的填充率杀手因为它每个像素都要采样多次纹理、还要做混合。所以移动端上我一般会砍掉真实抓屏折射改用假折射或者干脆降低冰体的屏幕占比。还有一个技巧是把 Shader LOD 分级低配设备走简化版本用#pragma multi_compile或者多个 SubShader 来实现。第二个是Overdraw。半透明物体是叠加渲染的如果一个屏幕区域被五六层冰叠着那块区域就要算五六遍。全局的就尽量避免让多个冰体在屏幕空间重叠或者在远处自动降级为不透明材质。第三个是XR 的立体渲染。在单通道立体渲染模式下屏幕纹理是双眼拼合的宽度是实际宽度的两倍。如果直接按普通屏幕 UV 采样会把左眼的内容采到右眼里去视觉上就是错乱的。前面提过用 Shader Graph 的 Scene Color 节点或者 URP 的官方函数能规避掉但如果你手写 HLSL就必须自己处理unity_StereoEyeIndex和 UV 的重映射。这块我踩过一次坑调了整整一个下午才定位到是立体渲染的坐标问题。另外还有一点VR 里玩家的头部一直在动视角变化会放大折射的瑕疵。屏幕空间折射在头部快速转动的时候容易出现游泳感——画面像是在水里波动。缓解的办法是降低折射强度让冰更依赖法线凹凸和边缘光来表现而不是靠屏幕扰动。这一点和桌面端的设计思路是完全相反的。5. 参数调优与常见问题排查效果能跑起来只是第一步能稳定地跑在真实项目里才是终点。这一章整理我在实际项目中遇到的高频问题和处理方式。5.1 透明排序、深度写入与穿插穿帮排序问题是透明材质的通病。半透明的冰如果和不透明的墙靠得很近或者两块冰相互穿插很容易出现该透的没透、该遮的没遮。排查的时候先看渲染队列确认冰的 Queue 确实在 Transparent 段大于 2500。然后看有没有其他半透明物体用了相同的队列相同队列的物体之间是没法保证顺序的需要手动错开到 3000、3010、3020。如果必须保证前后关系比如前景的冰必须挡住后面的冰可以考虑把冰改成不透明用 Alpha Test 裁掉边缘。牺牲了透明感但稳定性大幅提升。我在做小游戏的时候就吃过这个亏——上线前一周发现某些机型的冰块会闪烁最后妥协改成了半透明加抖动噪点的 Alpha Test 方案反而看起来还挺有风格。还有一个常见的穿帮是冰膨胀把物体戳出模型外面。前面讲了_Expand会放大顶点如果物体的某些部位很薄比如剑刃、发丝外扩之后会直接脱离原位。解决办法是把膨胀限制在法线朝向摄像机的分量小的区域或者干脆用顶点色限制只在模型的躯干部分膨胀。5.2 性能预算与降级方案我给几个实测参考值。在骁龙中端手机上用假折射的冰 Shader单个冰体占屏 15% 左右DrawCall 增加 1GPU 时间大约增加 0.4 到 0.8 毫秒。如果用真实的_CameraOpaqueTexture采样这个数会翻到 1.2 到 2 毫秒因为它多了一次全屏纹理采样和一次屏幕抓取。所以如果场景里有五个冰体同时在屏幕上用真实折射就很可能吃掉 10 毫秒直接掉帧。降级策略我通常分三档。高档真实折射加高分辨率抓屏加全套效果。中档假折射加菲涅尔加高光去掉裂纹发光。低档只有菲涅尔边缘光加一个纯色混合连法线贴图都不采。用质量等级或者机型白名单来决定走到哪一档。还有个更简单的做法是基于屏幕占比动态降级——在脚本里每帧算一下物体的屏幕包围盒面积超过阈值就用高档反之降档。这个方案实现简单效果也直观。注意Shader 变体数量会直接影响打包时间和内存。如果你写了很多#pragma multi_compile的组合变体数量会指数增长。项目后期一定要用 Shader Variant Collection 或者 Strip 变体来清理否则打包出来的包体会大得离谱。5.3 问题速查表下面这张表是我自己整理的速查清单遇到问题先对一遍能省下不少瞎试的时间。现象可能原因处理方式冰完全不透明看不到背后ZWrite 没关 / Blend 模式写错检查 ZWrite Off 和 Blend SrcAlpha OneMinusSrcAlpha冰块整体全黑抓屏纹理采样失败UV 超出范围检查 grabUV 的 w 除法确认平台 Y 翻转折射方向错乱法线没转到世界空间或漏乘 tangent.w补上 TBN 构造检查副切线符号边缘光是全白的死边菲涅尔直接用加法没过曝保护改用 lerp 混合别用加法冻结边界是一道直线噪声没起作用或振幅太小检查噪声 UV 缩放Amp 提到 1.2 以上冰块闪烁多个透明物体队列相同手动错开 Render Queue移动端掉帧严重抓屏开销大 / Overdraw 高改假折射限制同屏冰体数量Shader Graph 里法线没用Master 节点没接 Normal 输入把法线接进 Master 的 Normal 端口顶点外扩后模型穿帮薄壁部位膨胀过度用顶点色或曲率限制膨胀范围冻结后模型没有体积感缺少顶点膨胀或膨胀值太小逐步提高 _Expand 到 0.02 至 0.05排查的时候有个通用思路一次只改一个变量。我见过太多人调效果的时候一次性改了五六个参数结果效果变好了也不知道是哪个起作用换个模型又不行了。养成每次只动一个参数、动完截图对比的习惯长期来看效率高得多。还有个小技巧做调试的时候可以在片元最后加一句return fixed4(mask, mask, mask, 1);把冻结遮罩直接输出成灰度图这样你能直观看到遮罩的形状对不对、边界自然不自然、有没有奇怪的黑斑。确认遮罩没问题之后再改回正常的输出。我在做每个复杂效果的时候都会先单独把遮罩调好这一步能省掉后面大量的返工。6. 玩法联动与效果扩展效果做出来之后怎么和玩法结合才是决定它是否出彩的地方。这一章聊两个扩展方向。6.1 冻结扩散方向从受击点向外前面用的竖直梯度适合物体整体被冻住的场景但游戏里更常见的是从受击点向外扩散。实现方式是脚本把受击点的世界坐标传进 Shader片元里算distance(worldPos, _HitPoint)再用这个距离去生成梯度——距离越小越先冻住。这样一次命中打在哪里冰就从哪里开始蔓延打击反馈会明显很多。这里有个细节直接线性映射距离会让扩散看起来是匀速的但实际上玩家对边缘速度的感知更敏感所以我一般会给距离加一个曲线比如pow(saturate(1 - d / radius), 0.6)这样刚开始快、接近边缘慢看起来更自然。然后这个值再和噪声扰动相乘得到最终的遮罩。如果要做多个冻结源同时扩散的效果比如被冰霜新星覆盖可以用一个循环把多个点的贡献取 max 或者相加但要注意性能源的数量别超过四个而且同等距离下取 max 比相加更自然——相加会让重叠区域过亮看起来像是被打了两遍光。6.2 全屏冰冻后处理的思路除了物体级的冰还有一种效果是全屏结冰——技能命中玩家时整个屏幕被冰霜覆盖。这个用后处理实现思路和物体级的不一样。后处理的做法是在相机渲染完之后用一个全屏 Blit 把屏幕画面和一张冰霜贴图叠加。冰霜贴图是美术画的四周厚中间薄或者反过来根据效果需求。然后做一个屏幕空间的折射扭曲采样屏幕纹理时加一点法线扰动。再加上一圈暗角让边缘更暗营造出被冰包裹的压迫感。参数用一个全局的强度值控制从 0 到 1 的渐变配合协程做进出。这个方案的开销主要在全屏采样的次数如果只是叠一层图加一点扰动成本是很低的移动端也能接受。要注意的是它和 URP 的后处理栈集成需要写 ScriptableRendererFeature稍微有点门槛但社区里有很多现成的模板可以改。最后分享一个我在实际项目里总结的小经验做冰冻效果的时候先做减法再做加法。一上来就把折射、裂纹、发光、膨胀全加上很容易得到一坨又糊又花的半透明东西。正确的顺序是先把遮罩和透明度调好确认轮廓清晰再逐步叠加每一个视觉层每加一层都截图对比确认它确实带来了提升。我最初做的那个蓝色塑料袋问题就出在每层都没调到位就急着往上堆。另外一个体会上冰的观感很大程度上取决于光照环境。在暖色调的场景里冰的冷色对比会非常跳效果很讨喜但在整体偏冷的场景里同一套参数做出来的冰就会糊成一片完全看不出层次。所以效果定稿前一定要在项目的真实场景光线里验收而不是在灰色的测试场景里调参数。这一点我吃过亏测试场景里看着晶莹剔透的冰放进雪地关卡之后完全没影了最后不得不把霜色明度整体提了 15%才重新把轮廓找回来。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。