ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Unity Surface Shader实战:法线UV扭曲打造金箔纸材质

Unity Surface Shader实战:法线UV扭曲打造金箔纸材质

做材质这件事,遇到“金箔纸”需求时,很多人的第一反应是找一张金色Metallic贴图直接往金属材质上一扔。但出来的东西总差口气,要么平得像刷了层漆,要么高光糊成一片。上周有个做电商包装渲染的朋友发来一张金色镭射纸的实物照片,问能不能在五分钟内出一个差不多的材质球。这个效果看着花哨,拆开看核心就是三件事:法线贴图提供褶皱起伏,UV采样坐标被法线数据扰动,再配一个能反射环境的自定义光照模型。也就是标题里说的法线UV扭曲,常被用来做金箔纸、镭射包装、金属信纸这类表面自带细微流动光泽的材质。

这篇文章我直接按Surface Shader的写法,把原理、代码、参数和踩坑全过一遍。适合想快速搞出金属箔纸材质、又不想花太多时间啃完整PBR理论的朋友。代码可以直接抄,但建议先看完解释,不然改起参数来容易翻车。

1. 金箔纸效果的视觉本质:法线如何“扭曲”UV感知

1.1 法线贴图不是颜色贴图

要理解法线UV扭曲,得先破除一个惯性思维:法线贴图里存的不是颜色,而是方向。这张偏蓝紫色的贴图每个像素的RGB通道,经过UnpackNormal解码后,得到的是切线空间下的一个三维向量,其中Z轴默认朝向物体表面外侧。表面看似平坦,但在光照计算时,每个片段都按这些方向被“顶”了一下,高光、漫反射的分布就会随之变化。

金箔纸和普通金色金属的区别就在这。普通金属的表面法线变化幅度很小,高光是一整块光滑的光斑;而真实金箔纸经过压纹、揉搓,微观表面坑坑洼洼,法线方向剧烈摆动,高光被拆成无数细碎的小光点。想用纯程序化噪波去模拟这堆凹凸,计算量吃不消,直接用一张设计好的法线贴图是性价比最高的办法。

1.2 所谓的“UV扭曲”其实是采样坐标被打乱

很多朋友一看到“UV扭曲”就以为是去修改网格的UV数据,或者在顶点阶段拉伸纹理坐标。真正金箔纸效果里的UV扭曲,发生在片元着色阶段,而且根本不动网格,只是把传给tex2D的采样坐标临时加上一个由法线构造的偏移量。

表面微观法线方向变化,等效于让“视线穿过表面”的路径发生偏折。在着色器里做一件很微妙的事:让每个像素不采自己那个UV位置的颜色,而是采“被法线推了一下”的邻域位置的颜色。这样原本排列规整的纹理图案,在屏幕上看起来就像被揉皱了一样,微观起伏被直接转译成了纹理形变。

拿生活中常见的东西来类比,就是一面平整的镜子和一张揉皱的锡纸。镜子反射的是规则图像,锡纸反射出来的图像是破碎的、方向混乱的。因为锡纸表面每个微小平面朝向都不同,入射光线被弹向四面八方。法线UV扭曲做的事情,就是让“每个像素各自拥有一个微小的朝向偏移”,在光照和采样的双重作用下,拼出箔纸特有的破碎光泽。

1.3 金箔纸质感的分界线在哪

如果只做法线扰动,不配合UV扭曲,得到的效果更接近磨砂金属,而不是箔纸。金箔纸的观感有两个关键特征:

第一,反射光呈鳞片状破碎。这意味着反射向量必须被法线剧烈拉扯,普通PBR里的法线强度往往不够,需要把法线的xy分量额外放大,并且让它参与环境采样方向的计算。

第二,卷曲纹理有流动感。当视角或光源变化时,箔纸表面的纹路像液态金属一样“游动”。这种观感单纯靠静态法线贴图很难出现,必须让扰动还作用于纹理坐标本身,甚至叠上一层缓慢流动的偏移。把这两点结合,才算摸到金箔纸效果的门槛。

2. Surface Shader实现:完整代码与核心逻辑拆解

2.1 为什么选Surface Shader而不是手写Unlit

Unity里实现这个效果有两条路:用Surface Shader,或者写完整的顶点/片元Shader。我做这个材质时选择Surface Shader,原因有三个:

一是光照模型可以自定义,但漫反射、阴影、雾、光照衰减这些基础交互它全都接管了,不用自己从头处理环境光和多个光源的累加。二是法线输出有专门的o.Normal接口,和Unity内部的光照管线天然兼容。三是做迭代实验时,改一个参数重新编译的反馈速度比手写Unlit快很多。

代价是可控性没有Unlit那么细,比如自定义光照函数里能拿到的数据依赖Unity传入的SurfaceOutput结构。但对金箔纸这种“需要光照参与”的材质来说,这个取舍非常划算。

2.2 一份可直接跑的SurfaceShader示例

下面这个Shader是我调试过多个版本后留下来的基础模板,完整支持自定义高光、环境反射、双重复用法线扰动。把它直接扔进Unity工程,建一个材质赋给球体或平面就能看到效果。

Shader "Foil/GoldFoilSurface" { Properties { _MainTex ("Albedo", 2D) = "white" {} _BumpMap ("Bump Map", 2D) = "bump" {} _BumpScale ("Bump Scale", Range(0.2, 4.0)) = 1.6 _BumpTwist ("Bump Twist Amount", Range(0.001, 0.3)) = 0.06 _UVTiling ("UV Tiling", Range(1, 24)) = 6 _FlowSpeed ("Flow Speed", Vector) = (0.05, 0.12, 0, 0) _GlossStrength ("Gloss Strength", Range(0.0, 1.0)) = 0.65 _SpecGloss ("Spec Gloss", Range(1, 64)) = 16 } SubShader { Tags { "RenderType"="Opaque" } LOD 200 CGPROGRAM #pragma surface surf FoilCustom fullforwardshadows #pragma target 3.0 sampler2D _MainTex; sampler2D _BumpMap; float4 _MainTex_ST; float4 _BumpMap_ST; float _BumpScale; float _BumpTwist; float _UVTiling; float4 _FlowSpeed; float _GlossStrength; float _SpecGloss; struct Input { float2 uv_MainTex; float2 uv_BumpMap; }; // 自定义光照模型:主光高光 + 环境反射 inline half4 LightingFoilCustom (SurfaceOutput s, half3 lightDir, half3 viewDir, half atten) { half3 h = normalize (lightDir + viewDir); half nh = saturate (dot (s.Normal, h)); half spec = pow (nh, _SpecGloss) * s.Gloss * _GlossStrength; half ndl = saturate (dot (s.Normal, lightDir)); half3 diffuse = s.Albedo * _LightColor0.rgb * ndl * atten; // 反射向量参与环境采样,并用法线方向做二次扰动,模拟箔纸破碎反射 half3 refl = reflect (-viewDir, s.Normal); refl = normalize (refl + s.Normal * _BumpTwist * 3.0); half3 env = UNITY_SAMPLE_TEXCUBE (unity_SpecCube0, refl).rgb * _GlossStrength; half4 c; c.rgb = diffuse + env * s.Albedo + spec * _LightColor0.rgb; c.a = s.Alpha; return c; } void surf (Input IN, inout SurfaceOutput o) { float2 baseUV = IN.uv_MainTex * _UVTiling; // 第一次法线采样,决定表面明暗起伏 float3 n1 = UnpackNormal (tex2D (_BumpMap, IN.uv_BumpMap * _UVTiling)); n1.xy *= _BumpScale; n1.z = sqrt (1.0 - saturate (dot (n1.xy, n1.xy))); // 核心操作:把法线的xy当作UV偏移量,制造视觉扭曲 float2 uvFoil = baseUV + n1.xy * _BumpTwist + _Time.y * _FlowSpeed.xy; // 错位叠加第二次法线,避免大面积周期重复感 float3 n2 = UnpackNormal (tex2D (_BumpMap, IN.uv_BumpMap * _UVTiling * 1.7 + 0.317)); n2.xy *= _BumpScale * 0.35; n2.z = sqrt (1.0 - saturate (dot (n2.xy, n2.xy))); float2 uvFoil2 = baseUV * 1.3 - n2.xy * _BumpTwist * 0.8 + _Time.y * _FlowSpeed.yx; o.Normal = normalize (n1 + n2); o.Albedo = tex2D (_MainTex, uvFoil).rgb; half3 albedo2 = tex2D (_MainTex, uvFoil2).rgb; o.Albedo = lerp (o.Albedo, albedo2, 0.35); o.Gloss = 0.5; o.Specular = _SpecGloss; o.Alpha = 1.0; } ENDCG } FallBack "Diffuse" }

2.3 逐段拆解:每个变量和步骤是干什么的

属性的命名尽量直观。_BumpScale控制法线强度,1.0是正常,拉到1.6以上会让表面起伏变得更夸张,适合箔纸这种需要“强烈褶皱感”的材质。_BumpTwist是UV扭曲量的核心参数,它实际上决定的是法线xy分量在纹理坐标上被放大的倍数。这个值太小,纹理几乎不被扰动;太大,则纹理花成一片,像电视机雪花。

surf函数里的第一段法线采样用主UV做了一次Tiling缩放。为什么Tiling要设这么高?因为真实金箔纸的褶皱频率很高,如果UV平铺太少,褶皱会像丘陵一样又大又缓,完全不是那么回事。_UVTiling给到6到10之间,才能得到细密的箔纸纹理。

n1.xy乘以_BumpScale后,重算n1.z的步骤不能省。法线贴图解码后本来是单位向量,但一旦放大了xy分量,向量长度就不再是1了。如果不重算z,光照模型拿到的法线长度异常,高光和漫反射都会出现不可预期的过曝或变暗。这里n1.z = sqrt(1 - dot(n1.xy, n1.xy)),是用单位向量长度恒为1这个约束,把被拉长的分量拉回单位球面上。

接着用n1.xy乘_BumpTwist加到基础UV上,得到uvFoil。这就是之前章节提到的“虚拟UV”——纹理像是被表面起伏推着移动了。注意uvFoil同时也加上_Time.y * _FlowSpeed.xy,让扰动随时间缓慢漂移,模拟箔纸在不同光照角度下的流动感。

第二段法线采样是为了打破重复感。单张法线贴图无论怎么Tiling,放大后都会出现明显的周期规律。第二张采样把UV再乘1.7倍,并加一个固定偏移量0.317,相当于在“另一套频率”上扰动,两张叠加后规律性会明显下降,接近真实褶皱那种无规则破碎感。这里0.35倍的BumpScale不是乱给的,第二层扰动如果强度太高,会让第一层完全被盖住,表面看起来像噪声等离子体,而不是金属箔纸。

自定义光照函数LightingFoilCustom里,高光部分用半角向量H和法线点乘,pow的指数是_SpecGloss。这个指数越接近64,高光越锋利,箔纸的鳞片感越强;指数越小,高光越分散,偏磨砂。环境反射部分先reflect出反射向量R,再在反射向量上叠加一个由s.Normal乘_BumpTwist构造的偏移。这一步很巧妙,等于让环境映射本身也产生“鳞片状”撕裂,配合UV扭曲,视觉上就形成了双重破碎:颜色被扭曲,高光和环境也被扭曲。

3. 参数调校从“塑料箔纸”到“真金箔纸”

3.1 法线强度和UV扭曲量的配合

把Shader跑起来以后,第一步先别急着调色,把_BumpScale和_BumpTwist分别拉到两个极端看看。_BumpScale太小时,褶皱平缓,高光区域是一整片平滑的金色,看起来更像抛光黄铜;_BumpScale过大时,高光彻底被打散,漫反射也带着强烈的闪烁噪点,画面开始晃眼。同理,_BumpTwist太小,纹理几乎未被扭曲,只是普通法线凹凸;太大,纹理被撕裂,高温导致贴图糊成一团。

给一个我实测下来比较稳的区间:金属色贴图的情况下,_BumpScale设在1.2到2.2之间,_BumpTwist设在0.04到0.10之间。入门可以先取1.6和0.06,然后按需微调。想突出“揉皱”的纸张感,就把_BumpScale往上走;想突出“细腻压纹”的镭射感,就压低_BumpTwist、调高_UVTiling。

3.2 三组常见质感的参数速查表

不同逼真度需求下,参数组合差异很大。以下三组参数是我分别为了几个不同用途调出来的,可以直接套用再微调。

质感方向_BumpScale_BumpTwist_UVTiling_SpecGloss_GlossStrength适用场景
沉稳金箔包装1.20.044320.5礼盒、书籍封面
细密压纹镭射纸2.00.078240.7包装袋、装饰面板
强破碎度揉皱箔纸2.80.126120.85艺术化道具、特效材质

第一组参数最“安静”,纹理扭曲和法线扰动都克制,在静止镜头下看起来像高级烫金纸。第二组是我最喜欢用的日常组合,箔纸感明显但不过度喧哗。第三组是给特写镜头准备的,表面有非常强烈的光斑撕裂感,类似真的揉成一团再展开的金箔。

3.3 让箔纸“流动”起来的关键技巧

静态参数调得再好,没有动画,金箔纸顶多算“仿金属纹理纸”。真实箔纸在光源移动或者物体转动时,纹理会呈现一种液体般的流光。这种流动感来自两个方面:一个是_FlowSpeed直接作用于纹理坐标,让采样位置随时间推移;另一个是反射向量的扰动,让环境高光跟着视角跳动。

我的经验是,两个方向的流动速度不能一致。_FlowSpeed设置为(0.05, 0.12),第二个流动方向用_YX的对称交换,再乘以一个不同的权重。这样两套采样各自拥有独立的速度场,叠加后就会产生多层错落的光感。如果两套速度向量完全一样,相位差的优势会大幅削弱,表面看起来就像单纯的平移,缺少“撕扯感”。

4. 没踩过这些坑,别说你会调法线UV扭曲

4.1 法线贴图压缩格式导致的断层

这是最容易踩的坑。某些平台上,法线贴图会被压缩成DXT5nm格式,原始RGB信息被重新分布到AG通道。UnpackNormal函数内部会处理这种格式,但如果你图省事,自己写一行类似fixed3 n = tex2D(...).xyz * 2 - 1的代码,在这个压缩格式下就会得到完全错误的数据,表面会出现明显的硬棱线和方形色块。

正确的做法是统一使用UnpackNormal。如果你需要调整法线强度,也要先UnpackNormal,再修改xy分量,最后重算z。顺序不能反,先改采样结果再重新向量化,才能保证方向空间一致。

4.2 Mipmap导致扰动在远处“糊死”

金箔纸的细节高度依赖高频扰动。在没有开启Mipmap时,贴图在近处和远处都保持同样的采样行为,扰动效果很清晰。但Unity默认会为纹理自动生成Mipmap,当物体在屏幕上的投影较小时,GPU会取层级更低的模糊Mip,法线贴图和主纹理的细节都会被吞掉,箔纸效果直接消失,变成一块秃秃的金色。

要解决这个问题,可以在纹理导入设置里关闭法线贴图的Mipmap,或者在代码里用tex2Dlod强制指定Mip级别。但要注意,关闭Mipmap会导致远距离闪烁和性能下降,对于大多数场景,保留Mipmap但把法线强度适当调高一点,是一个更实用的取舍。

4.3 扰动乘到UV上以后,Wrap模式失控

法线xy方向可能具有正负值,乘上_BumpTwist后,采样坐标很可能越过0到1的边界。如果贴图的WrapMode是Repeat,越界后会跳到另一侧取像素,产生明显的接缝和镜像感;如果是Clamp,边界会拉成一条线,看起来像污渍。

金箔纸通常需要细腻的平铺纹理,所以最适合的模式其实是Repeat,但前提是贴图本身设计为无缝可平铺。如果美术给的是一张带边框的贴图,Repeat模式下扭曲必然撕开边界。这种情况下需要把偏移量映射回0到1区间,也就是用frac函数做小数回绕,避免直接越界采样。

4.4 自定义光照模型返回全黑或高光不正

很多新手把光照函数名字写对了,但内部拼写错了一个变量,导致整个表面变成黑色。自定义光照函数里需要用到Unity内置的_LightColor0,如果名字拼错或者没有引入对应的头文件,编译器不会报错,运行时则表现为完全没有主光照影响。还有一个更容易忽视的点:光照函数必须在#pragma surface一行中指定对应名称,比如上面代码里的surf FoilCustom。这个名称不一致时,Unity会回退到内置Lambert,你的自定义反射和环境采样逻辑全部失效,效果瞬间变得平淡。

4.5 扰动过度,其实是在烧性能

法线UV扭曲看起来简洁,但本质上是片元着色器里额外增加了两次法线贴图采样和主贴图采样,远距离高密度的场景下开销不小。尤其是_UVTiling调得很高时,纹理Cache命中率下降,采样成本进一步攀升。做手游或VR项目时,建议把两张叠加法线改为一张,以0.5的权重混合即可,或者用法线贴图的第一Mip做低效替代。别为了一个近景特写材质把整个场景的帧率拖下水。

5. 常见问题速查表和参数含义对照表

5.1 问题排查表

症状可能原因解决办法
表面高光糊成一片_BumpScale太低或_SpecGloss指数过低提高_BumpScale到1.5以上,调高_SpecGloss到24以上
纹理像雪花一样花掉_BumpTwist过大,采样坐标过度偏移将_BumpTwist降到0.05以下,必要时配合Tiling降频
表面有明显方形色块法线贴图压缩格式兼容问题使用UnpackNormal而非手动解码
远处完全看不到箔纸Mipmap把细节吞掉了提高_BumpScale或关闭法线贴图Mipmap
采样坐标越界出现接缝WrapMode和贴图内容冲突改为Repeat平铺贴图或用frac做UV回绕
自定义光照不生效#pragma surface函数名不一致确保光照函数名和编译指令统一
物体转动时高光闪烁剧烈第二层法线权重过高将第二层法线的_BumpScale降到第一层的1/3以下

5.2 核心参数公式速记

法线UV扭曲本质是在做一次坐标重映射。简化公式如下:

uv_final = uv_base * tiling + normal_xy * bump_twist + time * flow_speed

不要把这个公式当成死规矩。实际项目里,normal_xy前面可以再接一个强度系数,flow_speed也可以改成两个方向叠加。理解了这个公式,就能明白为什么调整_BumpTwist会产生“扰动感”,为什么调_UVTiling会产生“频率感”,为什么_FlowSpeed会让整张箔纸“活”过来。

6. 扩展方向:这套理论不只是金箔纸专用

金箔纸只是法线UV扭曲最直观的应用之一。把这个思路往旁边挪一挪,能做的材质非常多。

第一类,半透明包装纸。把上面代码里的主贴图换成半透贴图,并把Normal扰动乘上视角方向,就能模拟出糖果包装纸、扭结塑料袋那种光线在褶皱间穿透的折射感。重点是把o.Alpha改成半透并调节RenderQueue。

第二类,服装面料。丝绸和羽绒面料的高光破碎方式和金箔纸不同,但底层逻辑相似。把_BumpScale下降,_BumpTwist保持不变,配合一张细密的高频法线贴图,原本平整的面料就会呈现出水波纹般的织物阴影。

第三类,UI动效和HUD面板。在UI材质上用法线UV扭曲,可以让金属质感的按钮边框在鼠标经过时产生水流般的波动。性能开销小,视觉反馈却非常高级。具体做法是把_Time.y替换成一个由脚本动态改动的遮罩值,让扭曲只作用在按钮边缘区域。

在GameObject上配合流光遮罩,还可以做出“能量正在表面流动”的充能效果,本质就是法线扭曲方向不同。由于Surface Shader的写法足够模块化,拷贝一份再加一段判断,就能自由扩展。

最后再分享一个小技巧。如果你觉得金箔纸效果还是不够“纸”,问题可能出在反射颜色上。真实金箔纸不是纯镜面反射,它的环境反射会混入一些漫反射的暖色能量。把环境采样结果和Albedo都写入最终颜色,并且让两者互相调制,比如上面代码里的env * s.Albedo,会比简单相加更接近真实箔纸的感觉。

我在实际项目中推翻过很多个版本,最初一直试图用更高的Metallic和Smoothness去追效果,结果金属感越强越像抛光铜板。直到把注意力从PBR标准参数转移到法线扰动和采样坐标偏移之后,金箔纸的感觉才一下子出来。这大概就是这类“非物理但高度视觉化”材质的魅力:很多所谓物理参数,不如一个小技巧对视觉感知的撬动大。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进