法线贴图与切线空间:为什么是切线空间,怎么采样
法线贴图让一块平板看起来有砖缝和划痕——它存的是「每个像素的微表面朝向」。但朝向存在哪个坐标系里?为什么几乎总是切线空间?这篇按「目标 → 原理 → 关键代码 → 坑」把它讲透,这是所有效果章里最值得先拿下的一篇。
目标
网格不变,靠一张贴图改变表面法线,让光照(和 PBR 高光)按贴图细节响应。
原理:为什么是切线空间
法线贴图可以存世界空间法线(RGB 直接是 N),但没人这么做,因为:
- 镜像与复用:世界空间法线烘死了朝向,同一张贴图不能复用到朝向不同的表面,也不能镜像模型。
- 动画:模型旋转/蒙皮后,世界空间贴图立刻全错;切线空间贴图跟着表面走。
- 压缩友好:切线空间法线几乎总是 +Z 附近,信息集中在 RG 两通道,正好塞进 DXT5nm/BC5 的格式特性。
切线空间 = 以表面为基准的局部坐标系:切线 T(沿 UV 的 U 方向)、副法线 B(沿 V 方向)、法线 N。贴图里存的是「相对本表面倾斜了多少」的法线。要参与光照,要么把贴图法线变到世界空间,要么把光/视线变到切线空间——两条路等价,代码里选一条。
关键代码:Built-in 风格(把光变到切线空间)
// 顶点输入必须带切线:
struct Attributes {
float4 vertex : POSITION;
float2 uv : TEXCOORD0;
float3 normal : NORMAL;
float4 tangent : TANGENT; // w 是镜像符号 ±1
};
// 顶点程序:
v2f vert(Attributes v) {
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
o.uv = TRANSFORM_TEX(v.uv, _MainTex);
TANGENT_SPACE_ROTATION; // UnityCG 提供:模型空间→切线空间的旋转矩阵
o.lightDir = mul(rotation, ObjSpaceLightDir(v.vertex)).xyz;
o.viewDir = mul(rotation, ObjSpaceViewDir(v.vertex)).xyz;
return o;
}
// 片元程序:
fixed4 frag(v2f i) : SV_Target {
fixed3 bump = UnpackNormal(tex2D(_BumpMap, i.uv)); // 解包成 [-1,1] 切线空间法线
bump = normalize(lerp(float3(0,0,1), bump, _BumpScale)); // 强度控制
fixed3 L = normalize(i.lightDir);
fixed3 V = normalize(i.viewDir);
fixed ndl = max(0, dot(bump, L));
fixed3 H = normalize(L + V);
fixed spec = pow(max(0, dot(bump, H)), _Shininess);
return tex2D(_MainTex, i.uv) * (ndl + 0.1) + spec * _SpecColor;
}
URP 走另一条等价路线:顶点输出世界空间 TBN,片元里 NormalMix/ApplyNormal 把贴图法线合成世界法线(CreateWorldSpaceNormal)——原理相同,只是变换方向反过来。
三个关键函数:
UnpackNormal:按纹理导入格式解包。Normal Map 类型纹理(BC5/DXT5nm)只存 x(在 A 通道)和 y(在 G 通道),z 由sqrt(1-x²-y²)重建——所以贴图必须按 Normal Map 类型导入,按 Default 导入再手动解包会全错。- TBN 的来源:
TANGENT语义的数据由网格导入计算(Model Import Settings → Tangents: Calculate,MikkTSpace 算法与烘焙工具一致)。UV 接缝处顶点被拆分,两侧切线不同但结果连续。 tangent.w:±1 镜像符号,cross(T,N)得到 B 时要乘它(见 /notes/unity/principles-5/)。
常见坑
| 症状 | 原因 |
|---|---|
| 细节明暗方向反了 | 贴图绿色通道没按 Normal Map 导入,或外部工具与 Unity 的 Y 方向约定不一致(DirectX/OpenGL 翻转) |
| 接缝处有明显折痕 | 网格没开切线导入 / 自定义网格没提供 TANGENT 数据 / UV 接缝两侧切线不一致 |
| 镜像的物体光照不对称 | 忘了 tangent.w 符号(自定义 TBN 手写时) |
| 高光对但漫反射怪 | 只替换了高光用的 N,漫反射还在用插值来的几何法线——两处要用同一个 bump 法线 |
| 平贴图也显得凹凸 | 贴图不是中性 (128,128,255) 或 _BumpScale 没归零 |
与 PBR 的接合
PBR 公式里所有用到 N 的地方(D 的 N·H、G、菲涅尔 N·V、漫反射 N·L)都必须用合成后的法线,见 /notes/unity/lighting-3/。粗糙度贴图与法线贴图共用细节时注意:划痕既要改法线也常要改粗糙度,两张图要同源制作。
自检三问
- 为什么世界空间法线贴图不能复用到旋转后的模型上?
UnpackNormal之前,贴图必须满足什么导入条件?- 自定义网格想用法线贴图,语义上缺什么会直接失败?