做冰纹理折射这个效果,最早是因为在做一个冰雪主题的场景,需要冰面既能透出底下的物体,又要有那种扭曲变形的视觉感受。一开始想直接用普通的玻璃Shader换贴图,但效果怎么都不对——要么太平、太死板,要么就是完全的模糊,丢了冰面该有的“通透感”。后来换成了扭曲着色器的思路,在折射的基础上加了噪声扰动和冰面裂纹细节,才把那个味道调出来。这篇文章就聊一聊用Unity实现冰纹理折射的完整过程,包括Shader Graph和手写Shader两种方式的核心逻辑、参数怎么调、以及我踩过的几个坑。
1. 冰纹理折射的整体设计与方案选型
1.1 核心思路:折射是假的,但看起来是真的
先说结论:冰纹理折射这种效果,在大部分实时渲染场景里都不是真的去算光线折射路径,而是用屏幕空间采样做近似模拟。原理其实一句话就能说清——把屏幕上已经画好的背景、物体、光影,当成一张贴图,然后用“偏移后的UV”去采样它,采样位置根据冰表面法线和视角方向计算,越倾斜的地方偏移越大。这就像你隔着玻璃看东西,物体本身的形状没变,但背景像素在你眼前被“推”走了。
这个思路对冰面尤其合适,因为冰和玻璃最大的区别不是折射率,而是表面带有随机起伏和内部裂纹。冰的表面不是完美光滑的平面,天然带有微小的坑洼、划痕、霜花,每一个微表面的法线方向都不同,导致光线穿过冰面后方向被打乱,视觉上就是屏幕像素被局部“揉皱了”。所以冰纹理折射的完整公式可以拆成两层:一层是全局的折射偏移,决定“冰有多厚、透明度多高”;另一层是局部的法线扰动,决定“冰面有多扭曲、多冻”。先用Fast Fourier Transform之类的噪声函数生成一个法线贴图,再把这个法线和视线方向点乘,算出每个屏幕像素的偏移量,最后去Grab Pass或者Scene Color节点里采样,就完成了。
为什么不用完整的光线追踪折射?因为太贵了。冰这种材质在全景环境里通常是大面积存在的,一堵冰墙、一片冰湖,如果每一帧都对每个片段做递归的光线求交,移动设备根本吃不消。而屏幕空间偏移的方案,一个GrabPass加一次纹理采样就行了,代价几乎可以忽略,视觉上又能骗过绝大多数人的眼睛。真要追求极限真实感,后续可以再加一层视差映射或者次表面散射,但作为基础效果,屏幕空间扭曲是性价比最高的起点。
1.2 为什么选择Shader Graph搭配自定义采样
我这次实现踩了一条比较“混合”的路线:主体用Shader Graph搭,遇到需要精细控制的屏幕空间读取和纹理通道解析,就用Custom Function节点写HLSL。这么选主要有三个考虑。
第一,Shader Graph的可视化节点对美术和后续调整非常友好。冰的效果一个很重要的特点是参数多——扭曲强度、折射率、边缘渐变、霜冻程度、高光锐度——如果你把这些全部塞进一个500行的手写Shader里,改起来会怀疑人生。但Shader Graph里每个参数都变成一个滑块或者颜色输入,调起来直观得多,美术拿到项目以后也能自己动手改。
第二,纯Shader Graph在屏幕空间折射场景里有一个天然的痛——Scene Color节点的采样在URP里受渲染顺序和Opaque Texture开关的限制。尤其是在Built-in管线下,如果不开GrabPass,Shader Graph的Screen Color节点根本拿不到场景颜色。Custom Function可以让我显式声明GrabPass并控制采样的方式,这在兼容性上更稳。
第三,自定义HLSL节点可以让我复用同一个函数生成冰面裂纹纹理,然后把它同时用做法线扰动和颜色遮罩,省一遍纹理采样。这部分在纯节点图里也可以实现,但要连很多条线,可读性很差,用代码其实两三行就写完了。
所以最终的结构是:Shader Graph负责材质主循环和最终输出,HLSL函数负责屏幕空间采样和纹理通道解码,Shader属性块负责暴露参数。这样既有可视化调参的便捷,又能守住关键性能点,是折中之后最舒服的方案。
1.3 冰的视觉特征拆解:不只是要透明
做冰纹理折射之前,要先想清楚一个问题:用户期待看到的是什么样的“冰”?如果只看透射折射,那其实就是一块带扭曲的玻璃,看不出“冰”的感觉。冰区别于玻璃的核心视觉特征是三个:
第一个是“边沿白边”。大块的冰或者在光线斜射的位置,边缘处会有厚厚的冰霜堆积,呈现半透明白色,像上了霜的镜框。这种白边是冰面模型对材质的“厚度”提示,让透射和漫反射在模型边界处自然过渡。
第二个是“高光的锐利和拉伸”。冰的表面比玻璃粗糙一点点,但比石头光滑得多,所以高光应该是一个比较锐利的小点,并且沿法线扰动方向轻微拉伸。高光位置不能太规则,否则看起来像塑料;得有一点“闪烁”感,就是高光强度和位置随着视角晃动而轻微波动。
第三个是“裂纹和气泡”。纯色的透明材质很容易让人误以为是水或玻璃,冰的识别度很大程度来自表面随机分布的白裂纹和细小的半透明气泡。这些细节不需要很精细,用一张低分辨率噪声贴图乘法混合,能明显提升真实感。
明确了这三个视觉特征,后面的实现就有了目标:不仅要会扭曲屏幕空间采样,还要画好边缘白霜、控制高光锐度、叠加上裂纹细节。这一套组合拳打下来,才能让人第一眼认出来——这是冰,不只是玻璃。
2. 核心细节解析与实操要点
2.1 折射偏移的计算逻辑:从Snell定律到屏幕UV扰动
如果要严谨地说,光从一种介质进入另一种介质时会发生偏折,这个方向由斯涅尔定律决定。但实时渲染里,我几乎不会真的去算Snell定律的具体角度,因为视角、法线、折射率的联合计算需要多次变换到切线空间,成本不划算,而且最终像素差个零点几度的偏移,肉眼根本分辨不出来。
在实际工程里,大家更常做的是用一个近似的简化模型:偏移量 = 折射强度 * (视线方向与法线的夹角权重) * 法线扰动值。这个模型的行为是,当你看冰面的正上方时,几乎没有偏移,看到的是正下方的内容;当你斜视冰面时,偏移最大,能看到侧边的物体。这个行为至少“在方向上是对的”,视觉体验和真实冰面的折射趋势是一致的。
代码层面,Shader Graph里处理这块的核心是拿法线贴图解码后的normal值,转换到世界空间,然后用ViewDirection和这个法线做点乘或者直接做偏移。我自己的实现习惯是这样的:
// 冰面法线扰动,从法线贴图的R和G通道还原 float3 normalTS = UnpackNormal(tex2D(_BumpMap, uv)); // 转到世界空间 float3 normalWS = TransformTangentToWorld(normalTS, float3x3(tangentWS, binormalWS, normalWS)); // 视线方向(片元位置到摄像机) float3 viewDirWS = normalize(_WorldSpaceCameraPos - worldPos); // 计算视差偏移系数,视角越斜偏移越大 float offsetStrength = pow(1.0 - saturate(dot(normalWS, viewDirWS)), _EdgePower); // 屏幕UV偏移量 float2 screenOffset = normalWS.xz * offsetStrength * _DistortionAmount * 0.1;这个方案的一个小缺点是偏移方向会和真实折射有偏差,因为你直接用世界空间法线偏移屏幕UV,不看物体前后深度关系。但对冰这种较薄的平面材质,这个误差基本可以接受,实测下来沉浸感足够。
2.2 纹理通道的复用技巧:一张图搞定法线、裂纹和白边
冰表面纹理不需要很多张高精度贴图合成,我个人强烈推荐做一张“通道图”,把裂纹、气泡、霜冻信息都打包进一张RGBA纹理的不同通道里——R通道放裂纹密度,B通道放霜冻白边,A通道放气泡稀疏度,G通道留给人脸或可自定义的细节。这样做的好处是省采样次数,移动端每多一个纹理采样都可能成为GPU瓶颈。
通道拆解和混合的时候注意一点:冰的裂纹密度分布不应该是均匀的,而是集中在表面凹槽和受力线附近。所以R通道值高的地方,法线扰动应该更大、粗糙度更高,同时透明度要微降,因为裂纹处的散射和漫反射更强,透射更弱。B通道处理霜冻白边时,用smoothstep把阈值收紧,比如0.4~0.8之间输出0到1,避免白霜区域带出硬边。
Shader Graph里我用了一个Color Split节点把通道拆开,再把每个通道分别乘到不同的属性上。注意,这种通道图如果是从CrazyBump或者Substance Painter导出的,别忘了把sRGB采样关掉,纹理导入设置里的sRGB要选Off,否则通道里的线性数据会被压暗,混合结果会偏脏。
2.3 边缘光照与高光:冰的“灵气”从哪里来
边缘光照(Rim Light)在冰材质里几乎是必备项,它模拟的是光线在冰层内部多次反射后从边缘逃逸出来的效果。原理很简单:计算视线和法的夹角,夹角越接近90度说明越靠近边缘,就加强一条从视线方向反射回去的光。这就是“菲涅尔效应”的简化版。只不过玻璃的菲涅尔通常用来增加反射,冰的菲涅尔更适合用来增加边缘透射的亮度,因为光线在冰内部经历的散射比折射更强。
高光部分我用了两层Blinn-Phong,一层主高光,控制范围小但强度高,模拟冰面的镜面反射;一层副高光,范围大但强度低,模拟冰层的次表面散射。副高光的颜色不要用纯白,给一点点冰蓝(比如大概RGB值在(0.75, 0.9, 1.0)之间),会有冰层很透的错觉。这个细节我也是调了很多次才发现的,一开始副高光和主高光都是纯白,冰面看起来像涂了一层漆,换成淡蓝后,立刻“透”起来了。
3. 实操过程与核心环节实现
3.1 环境准备与Shader Graph基础配置
我用的是Unity 2022.3 LTS,URP管线,Shader Graph版本14.x。如果你用Built-in管线,操作会略有差异,主要集中在GrabPass的处理上,后面会单独提。创建材质之前,先确认项目的URP Asset里Opaque Texture是勾选状态,因为URP的屏幕空间采样依赖这个缓存。如果不勾选,Scene Color节点会采样失败,出来的效果要么是全黑,要么是纯色。
Shader Graph节点的整体流程可以概括为这么几步:UV输入→采样法线贴图→法线世界空间转换→计算屏幕空间偏移→Scene Color采样折射背景→叠加冰的基础色和透射色→叠加边缘光照和双层高光→处理通道图细节→输出到Base Color和Smoothness,Alpha走透明混合。透明混合需要把Surface Type切到Transparent,Render Face建议设成Both,因为有些冰面模型是单面的,剪影里看背面会露馅。
我把节点结构拆成几个子图表(Sub Graph),这样画面不会太乱,性能面板也能看得清。比如“Refraction”“FrostedEdge”“IceSpecular”各是一个Sub Graph,主图上只留几个大的节点单元,调试的时候也能快速定位问题。
3.2 手写Shader方案:想彻底掌控性能就这么写
如果你更习惯手写Shader,或者项目对包体和Draw Call极度敏感,那直接用ShaderLab写会更可控。下面的核心代码是我在Built-in管线里跑通的版本,直接复制到一个Shader里,配合材质面板调参数就能看到效果。
Shader "Custom/IceRefraction" { Properties { _BumpMap("Normal Map", 2D) = "bump" {} _DetailMap("Detail Map(RGBA)", 2D) = "white" {} _Distortion("Distortion Amount", Range(0, 2)) = 0.3 _EdgePower("Edge Power", Range(0.5, 4)) = 2.0 _Refraction("Refraction Strength", Range(0, 1)) = 0.5 _RimColor("Rim Color", Color) = (0.6, 0.85, 1.0, 1.0) _RimPower("Rim Power", Range(0.5, 8)) = 3.0 _FrostAmount("Frost Amount", Range(0, 1)) = 0.5 _SpecColor("Specular Color", Color) = (1, 1, 1, 1) _Shininess("Shininess", Range(0.1, 10)) = 2.0 } SubShader { Tags { "Queue"="Transparent" "RenderType"="Transparent" } GrabPass { "_BackgroundTex" } Pass { CGPROGRAM #include "UnityCG.cginc" #pragma vertex vert #pragma fragment frag sampler2D _BumpMap; sampler2D _DetailMap; sampler2D _BackgroundTex; float4 _BumpMap_ST; float4 _DetailMap_ST; float _Distortion; float _EdgePower; float _Refraction; float4 _RimColor; float _RimPower; float _FrostAmount; float4 _SpecColor; float _Shininess; struct appdata { float4 vertex : POSITION; float3 normal : NORMAL; float4 tangent : TANGENT; float2 uv : TEXCOORD0; }; struct v2f { float4 pos : SV_POSITION; float2 uv : TEXCOORD0; float2 uvDetail : TEXCOORD1; float4 grabUV : TEXCOORD2; float3 normalWS : TEXCOORD3; float3 viewDirWS : TEXCOORD4; float3 tangentWS : TEXCOORD5; float3 binormalWS : TEXCOORD6; }; v2f vert(appdata v) { v2f o; o.pos = UnityObjectToClipPos(v.vertex); o.uv = TRANSFORM_TEX(v.uv, _BumpMap); o.uvDetail = TRANSFORM_TEX(v.uv, _DetailMap); // 用Unity内置函数把顶点位置转到屏幕空间,供Grab采样使用 o.grabUV = ComputeGrabScreenPos(o.pos); o.normalWS = UnityObjectToWorldNormal(v.normal); float3 worldPos = mul(unity_ObjectToWorld, v.vertex).xyz; o.viewDirWS = normalize(_WorldSpaceCameraPos - worldPos); o.tangentWS = normalize(mul(unity_ObjectToWorld, float4(v.tangent.xyz, 0)).xyz); o.binormalWS = normalize(cross(o.normalWS, o.tangentWS) * v.tangent.w); return o; } fixed4 frag(v2f i) : SV_Target { // 法线解码和扰动 float3 normalTS = UnpackNormal(tex2D(_BumpMap, i.uv)); float3 normalWS = normalize( normalTS.x * i.tangentWS + normalTS.y * i.binormalWS + normalTS.z * i.normalWS ); // 菲涅尔系数:视线和法线夹角越大,边缘偏移越明显 float fresnel = pow(1.0 - saturate(dot(normalWS, i.viewDirWS)), _EdgePower); // 屏幕空间UV偏移,模拟冰层折射 float2 offset = normalWS.xz * fresnel * _Distortion * 0.1; float2 grabUV = (i.grabUV.xy / i.grabUV.w) + offset; // 透视除法后需要对偏心的UV做clamp,防止采到屏幕外 grabUV = clamp(grabUV, 0.001, 0.999); fixed4 bg = tex2D(_BackgroundTex, grabUV); // “透射色”:背景颜色混合一个冰蓝色 fixed3 refractive = lerp(bg.rgb, bg.rgb * _RimColor.rgb, _Refraction * 0.35); // 细节纹理:R=裂纹,B=霜冻白边,A=气泡 fixed4 detail = tex2D(_DetailMap, i.uvDetail); float crack = detail.r * _FrostAmount; float frost = smoothstep(0.35, 0.7, detail.b) * _FrostAmount; // 边缘白霜:用菲涅尔区域控制霜的覆盖范围 float edgeFrost = saturate(fresnel - 0.5) * 2.0 * frost; fixed3 finalColor = refractive; finalColor = lerp(finalColor, _RimColor.rgb, edgeFrost * 0.6); // 裂纹扰动叠加,让折射效果在裂纹处更强烈 float distortedByCrack = saturate(normalWS.x * crack * 3.0); finalColor = lerp(finalColor, bg.rgb * _RimColor.rgb * 1.2, distortedByCrack * 0.4); // 高光 float3 halfDir = normalize(i.viewDirWS + normalize(float3(0, 0, 1))); float spec = pow(saturate(dot(normalWS, halfDir)), _Shininess * 200.0); finalColor += spec * _SpecColor.rgb * (1.0 - frost * 0.5); // 最终Alpha:中心更透明,边缘更不透明,符合冰层厚度的直觉 float alpha = lerp(0.35, 0.95, fresnel + edgeFrost); return fixed4(finalColor, alpha); } ENDCG } } FallBack "Diffuse" }这段代码里有两个细节我觉得值得多说一句。第一个是ComputeGrabScreenPos,它返回的是一个齐次坐标,不能直接当UV用,要先做透视除法除以W分量,才能得到真正的屏幕UV。很多新手在这步直接把GrabUV丢给tex2D,结果是完全扭曲成旋涡状的画面。第二个是clamp屏幕UV到0.001和0.999之间,如果不做这一步,冰面边缘的采样会取到屏幕外的默认边缘像素,表现为一道黑色的描边,很丑。
真实项目里_BackgroundTex的GrabPass会采样当前帧所有已经画完的半透明物体之前的画面,因此它会拍到冰面背后的场景,但拍不到冰面本身。这个行为正好符合需求——冰面扭曲的背景是它后面的世界,而不是它自身。
3.3 Shader Graph版本:不用写代码也能实现
如果你更习惯Shader Graph,全流程大概这么走。创建一个Unlit Shader Graph,Surface Type设为Transparent,然后按下面几步串联节点:
- 创建两个Texture2D属性,BumpMap和DetailMap,分别导入Normal Map和通道图。
- 用Sample Texture 2D节点分别采样这两张贴图,BumpMap需要接一个Normal From Height或者直接用Sample Texture 2D的“Type”切换为Normal。
- 用Transform节点把法线从Tangent Space转到World Space。
- 用一个Dot Product节点计算法线和视线方向点乘,再用One Minus把它变成“边缘权重”,可以加一个Power节点做
pow(edge, _EdgePower)控制收紧程度。 - 把边缘权重和法线的xz分量相乘,再乘Distortion参数,得到屏幕UV偏移。这里需要把Scene Color节点的UV输入替换为“Custom Expression”节点提供的HCBUVP,或者在Screen Position节点里手动加入偏移——实测直接Add到Screen Position的输出上,再连到Scene Color的UV就行。
- Scene Color采样后乘上冰蓝色和Refraction控制,得到折射背景。
- Add一个乘了Rim Power的边缘光,再加一个Blinn-Phong高光节点或手动计算的高光,最后输出到Base Color和Alpha。
Shader Graph和手写代码的核心区别就在于屏幕UV的获取方式。在Shader Graph里,Screen Position节点输出的其实是和GrabUV等效的屏幕坐标(默认是Screen空间),你不需要手动做透视除法,它已经帮你处理了。所以在Shader Graph里做偏移就特别直观:连一个Add节点加上偏移量,进Scene Color的UV即可。
但要注意URP管线下Scene Color节点的前提条件,必须在URP Asset里把Opaque Texture勾上,否则这个节点回传的是全黑。这个坑我见过很多人在论坛上问,十有八九是忘了设置Opaque Texture。另外,Scene Color只记录不透明物体,如果场景里还有其他半透明物体(比如玻璃、水),它们不会出现在Scene Color里,冰面背后的玻璃会在冰里“消失”。这种时候就要考虑用CommandBuffer把半透明物体也抓进一张RenderTexture,或者接受这个限制,调整场景层的顺序把冰面放在所有半透明物体后面渲染。
3.4 参数调节思路:让冰从“玻璃”变成“冰”
参数调的是“度”,但更核心的是理解参数和视觉特征的对应关系。我给出一套我实际调通后很稳定的基线数值,你可以基于这个做微调:
| 参数 | 推荐值区间 | 作用 | 调节建议 |
|---|---|---|---|
| Distortion | 0.15 - 0.35 | 扭曲总强度 | 值太大会导致画面“撕碎”,雪花状噪点明显,建议从0.1起步往上加 |
| Edge Power | 1.5 - 3.0 | 控制菲涅尔过渡快慢 | 值越大,中心到边缘的扭曲过渡越突然,适合冰柱/冰棱 |
| Refraction | 0.3 - 0.6 | 背景透过率 | 太高会丢失冰面的存在感,低一点才有“隔着冰看”的感觉 |
| Frost Amount | 0.3 - 0.7 | 霜和裂纹覆盖度 | 冰龄越大,这个值越高;新冰建议保持在0.3以下 |
| Rim Power | 2.0 - 4.0 | 边缘光的衰减速度 | 决定白边宽度,越大白边越窄越锋利 |
| Shininess | 2.0 - 6.0 | 高光的锐利度 | 冰面要比水稍微锐利,过低会显得塑料 |
实际调的时候,建议把Distortion和Refraction这两个参数放在一起调。因为扭曲和透明是一对天然矛盾:扭曲太强但透过率太高,冰面看着像在沸腾;透过率太低但扭曲又弱,冰面看起来像镀了层白膜。我个人的调整习惯是先把Refraction固定在0.4,然后把Distortion从0开始一点点加,加到边缘出现轻微的“勾边”效果就停,这个位置就是当前场景下的最佳值。
还有一个小技巧:你可以给Shader暴露一个“IceAge”或者“FrozenLevel”的全局参数,用脚本来驱动,让冰面从一开始的接近透明玻璃,慢慢结霜、裂纹扩散,变成一个冰封了几百年的冰面。这种动态效果在游戏关卡设计里特别出彩,用Curve驱动法线扰动强度和霜冻值就行,几乎没有额外成本。
4. 常见问题与排查技巧实录
4.1 现象:冰面折射方向反了,物体倒着
排查思路:这个基本上都是法线空间转换出错了。如果你在Shader Graph里用了Normal Vector节点,却没有正确转换到World Space,或者手写Shader里TangentToWorld矩阵的基向量顺序不对,就会导致偏移方向正好相反,看到的世界上下颠倒。解决方法是先用一个颜色预览节点把normalWS输出到屏幕,把法线方向可视化出来——正常应该是冰面朝上的实体,如果预览出现大面积的绿色或红色,就说明空间转换矩阵和模型UV的切线方向对不上。
另一个常见原因是模型自身的UV翻转。特别是从Blender导入的模型,Y轴朝向和Unity不一致时,切线空间的TBN矩阵也会跟着出错。遇到这种情况,要么在建模软件里检查模型Y轴朝向,要么在Shader里手动翻转normalTS.y的符号。
4.2 现象:冰面边缘有一圈黑色硬边
排查思路:这是屏幕UV偏出屏幕范围导致的,很多材质都会有这个问题。当你斜视一大块冰面时,边缘的折射偏移会让采样UV超出0到1的范围,采样到的内容就跑到了屏幕外的未定义区域,通常是黑色。我解决的方法就是前面代码里的clamp到0.001~0.999,但如果你用的是Shader Graph,可以让Screen Position经过一个Saturate节点,直接限制在0到1区间。要注意的是Saturate会让边缘失去采样连续感,拉伸感也会很生硬,我建议用Lerp把越界区域和边缘色混合,这样效果更平滑。
具体操作是:算完偏移UV后,对它做一次difference取边界距离,然后用smoothstep把边界上的像素渐变成一个固定的冰蓝色,这样看起来就像冰面自然厚起来,而不是被裁掉。
4.3 现象:URP里效果全黑或一片白
排查思路:这个现象只要把URP Asset里的Opaque Texture打开就能解决。Opaque Texture本质上是URP为了屏幕空间效果,把不透明物体渲染到一张低分辨率RT上的一个Pass。它默认是关闭的,很多模板工程里都没开。开了之后Scene Color节点就能正常读取背景了,但注意Opaque Texture的分辨率默认是跟主相机分辨率一致的,开了之后会额外增加一段渲染开销,所以只在需要屏幕空间效果的相机上监听一个Camera组件动态开关,不要全局打开。
最后一个排查点是Built-in管线和URP的差异。手写Shader里我使用的是GrabPass,但GrabPass本质上是把整个屏幕抓到一张纹理里,和URP的Opaque Texture是两套完全不同的机制。如果你在URP里拿Built-in的GrabPass代码直接用,就会得到一片白或空效果,因为在URP里GrabPass默认支持不好。所以你在URP下做冰面折射效果,请务必走Shader Graph的Scene Color节点或者用URP的Render Objects Renderer Feature自定义Grab Pass,而不是把旧管线的代码拖过来。
4.4 现象:冰面穿帮,能看到背后物体浮在冰面上
排查思路:这是GrabPass/Scene Color采样的固有限制,它只记录不透明物体,如果冰面背后有另一个半透明物体,这个物体就不会被冰面折射到。解决方案是分层渲染,把冰面放在所有的半透明物体之后渲染,这样GrabPass抓到的是前一个半透明物体画完的画面,包含半透明内容的整个场景。做法是在脚本里设置冰面的RenderQueue,比如冰面Shader的Queue不写Transparent,而写Transparent+100或更靠后,就能排在大多数半透明物体之后。但这样也有代价——冰面后面如果有个冒烟粒子,数量太多时,冰面渲染顺序太靠后可能造成Overdraw上升,移动端会发热。我的经验是一般场景这样排没问题,但如果你做的是超大数据量的粒子冰河,还是优先接受这个限制,把粒子的半透明排序调整一下,而不是强行让冰面排到最后。
4.5 现象:冰面在移动端严重闪烁或性能掉帧
排查思路:冰纹理折射全屏扭曲,本质上是多了一个全屏的GrabPass/RT采样和全屏片段着色计算,移动端性能开销不能忽视。遇到掉帧,先看是不是分辨率太高——Opaque Texture如果是全分辨率,性能直接翻倍。实测在移动端把Opaque Texture分辨率设成0.5或0.25倍,视觉效果损失很小,性能提升却很明显。另一个优化点是不需要所有移动端设备都跑全屏扭曲,你可以用Quality Settings区分Android的高/中/低三档,低档直接降级为普通的半透明材质,不做扭曲,只保留颜色和边缘光,这样老设备也能跑得动。
还有个小细节:冰面的Shader里用了dot(normalWS, viewDirWS)计算菲涅尔,这个运算是逐片段的,会很吃GPU。如果World空间变换很贵,可以在Vertex阶段算一个近似的viewDir,再用varying传到fragment里插值,移动端差不了太多效果,但能省不少ALU指令。
5. 冰纹理折射的扩展方向与实践体会
冰纹理折射做到这一步,基础效果已经能拿得出手了。但如果你想让这个材质在项目中真正出彩,还有几个很值得扩展的方向。
第一个方向是冰的高精度细节——用视差映射做冰面的“厚度感”。把冰表面的高度图结合视差采样,让冰面在视角改变时产生真实的厚度遮挡,这样冰下的物体会因为冰层厚度的不同而呈现不同的扭曲程度。这个效果比单纯的屏幕UV偏移更真实,但代价是每个片段都要做多次采样,开销和时间要权衡好。
第二个方向是裂纹的扩展动画。冰裂纹不是静止的,新的裂纹会在低温下扩展。你可以用Shader里的噪声函数和时间变量,让裂纹在指定的区域内逐渐“长出来”——法线扰动强度随Distance Field渐进,形成类似冻裂的动态效果。这个在生存类游戏里做冰面崩溃、低温警告都特别有用。
第三个方向是加入次表面散射(SSS)。冰最勾人的视觉特征其实是光穿透冰层时的“内部发光感”。实现方式并不需要完整的SSS,可以用一个简单的双面渲染加背面透光亮度:渲染背面时,用该片元的法线和光线的夹角控制透光度,再把颜色加到正面的输出上。这样看起来就像冰的内部被光照透了,同时保持折射扭曲的屏幕空间特征。
我在实际项目里用的最多的其实不是参数堆叠,而是给材质写了一个完善的Inspector面板,把所有控制逻辑的参数都暴露出来,然后通过一个全局的“FrozenLerp”参数,让整个冰面可以在透明玻璃态和重度结霜态之间平滑过渡。这个做法的好处是,同一个材质既能在开场动画里当“刚冻上的河面”,也能在中后期变成“千年冰川”,不用换材质,动画只改一个float就行。
最后再分享一个小技巧:做一个冰面效果时,千万别只盯着Shader本身,模型的面数、UV接缝、背面剔除设置都要过一遍。冰这种材质最怕的就是UV接缝处的扭曲不连续——你调了半天的Shader,最后还是会被一条接缝出卖。所以冰面的模型尽量用连续UV,或者干脆程序化生成UV,这样折射偏移量在接缝处自然连续,出现“断层”的概率会小非常多。踩过几次坑之后我是彻底记住了:材质效果的三分靠Shader,七分靠模型和导入设置,冰尤其如此。