法线贴图与切线空间:为什么是切线空间,怎么采样

Unity 知识 · Shader 区域 · 常见效果 · 第 1 篇 更新于 2026-10-15

法线贴图让一块平板看起来有砖缝和划痕——它存的是「每个像素的微表面朝向」。但朝向存在哪个坐标系里?为什么几乎总是切线空间?这篇按「目标 → 原理 → 关键代码 → 坑」把它讲透,这是所有效果章里最值得先拿下的一篇。

目标

网格不变,靠一张贴图改变表面法线,让光照(和 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 之前,贴图必须满足什么导入条件?
  • 自定义网格想用法线贴图,语义上缺什么会直接失败?