1什么是 TBN 矩阵
法线贴图存储的并非世界空间法线,而是切线空间(Tangent Space)中的法线扰动。 切线空间由三个基向量定义:
| 向量 | 含义 | 方向来源 |
|---|---|---|
| T(Tangent) | 沿 UV 的 U 方向 | 顶点属性 float4 tangent 的 xyz |
| B(Bitangent) | 沿 UV 的 V 方向 | 由 T 和 N 叉乘得到 |
| N(Normal) | 垂直于表面 | 顶点属性 或面法线 |
将三者按列排列即得到 TBN 矩阵,用于把切线空间中的法线变换到世界/观察空间:
MTBN = [ T | B | N ] 其行列式 = T · (B × N) = ±1
行列式的正负即手性(handedness):+1 为右手系,-1 为左手系。 法线贴图的编码默认右手系------如果运行时 TBN 变成了左手系,凹凸就会反转。
2右手系 vs 左手系:直观对比

右手系下,T、B、N 满足右手定则:四指从 T 弯向 N,拇指指向 B。 镜像 UV 时,T 跟随 U 方向反转,如果 B 仍按 cross(N, T) 原样计算, 叉积结果也跟着翻转,导致 B 方向错误,TBN 变成左手系。
3镜像 UV:问题的根源
建模软件中,为了节省贴图空间,常对对称模型只展开一半 UV,然后镜像另一半。 典型场景:角色面部、左右对称的武器或载具。

镜像后 U 轴翻转,T 跟着翻转,但 cross(N, T) 的结果也随之翻转。 这意味着如果你只写 float3 B = cross(N, T),在镜像侧 B 的方向就错了------ 法线贴图里"凸"的地方会变成"凹",光照完全反常。
⚠️ 常见错误
在 Shader 中直接写 float3 B = cross(N, T) 而忽略 tangent.w,会导致所有镜像 UV 的物体法线贴图出现翻转。
4tangent.w:Unity 存储的手性信号
Unity 在导入网格时,会为每个顶点计算切线并存入 float4 tangent:
| 分量 | 存储内容 | 取值 |
|---|---|---|
tangent.x |
切线 T 的 x 分量 | −1 ~ +1 |
tangent.y |
切线 T 的 y 分量 | −1 ~ +1 |
tangent.z |
切线 T 的 z 分量 | −1 ~ +1 |
tangent.w |
副法线手性标志 | +1 (右手系)或 −1(镜像/左手系) |
tangent.w 的本质:它告诉你该顶点的 TBN 应该是右手系 还是左手系 。 非镜像顶点 w = +1;镜像顶点 w = −1。
B = tangent.w × cross(N, T.xyz)
当 w = +1 时,cross(N, T) 原样使用; 当 w = −1 时,叉积结果取反,将 B"扳回"正确方向,使 TBN 恢复右手系。
5URP Shader 中的正确写法
以下代码展示了在 Unity URP Shader 中构建 TBN 矩阵的标准做法:
cs
// 从 Interpolators 获取原始数据
float3 normalWS = TransformObjectToWorldNormal(input.normalOS);
float4 tangent = input.tangent; // xyz = T, w = 手性
// 将切线从对象空间变换到世界空间
float3 tangentWS = TransformObjectToWorldDir(tangent.xyz);
// ★ 关键:用 tangent.w 修正副法线方向
float3 bitangentWS = tangent.w * cross(normalWS, tangentWS);
// 归一化(正交化后的 TBN 各列应为单位向量)
tangentWS = normalize(tangentWS);
bitangentWS = normalize(bitangentWS);
normalWS = normalize(normalWS);
// 构建 TBN 矩阵(列向量排列)
float3x3 TBN = float3x3(tangentWS, bitangentWS, normalWS);
cs
// 采样法线贴图(DXT5nm / BC5 编码需要解码)
float3 tangentNormal = UnpackNormal(SAMPLE_TEXTURE2D(
_BumpMap, sampler_BumpMap, input.uv));
// 从切线空间变换到世界空间
float3 worldNormal = mul(tangentNormal, TBN);
// 注意:mul(vector, matrix) 等价于 mul(matrix转置, vector)
// 这里用行向量 × 矩阵,因为 TBN 是列向量矩阵
✓ 正确做法
第 9 行 tangent.w * cross(N, T) 是 Unity 的标准约定。无论网格是否镜像,tangent.w 都会自动修正副法线方向,保证 TBN 始终是右手系。
✗ 错误做法
如果写成 float3 B = cross(N, T) 而忽略 tangent.w,在镜像 UV 的物体上,法线贴图中的凹凸会完全反转:原本凸起的部分变成凹陷,高光位置也会错误。
6法线贴图翻转的视觉表现

7Unity 内置函数怎么做的
URP 提供了 GetWorldSpaceNormalizeTangent 等辅助函数, 它们内部已经正确处理了 tangent.w。查看源码可见:
cs
// Packages/com.unity.render-pipelines.universal/ShaderLibrary/SpaceTransform.hlsl
float3 GetWorldSpaceNormalizeTangent(float4 tangentOS, float3 normalWS)
{
float3 tangentWS = TransformObjectToWorldDir(tangentOS.xyz);
return normalize(tangentWS);
}
// 副法线计算在 LitInput 或自定义 Shader 中完成:
// bitangent = tangent.w * cross(normalWS, tangentWS)
URP 的 Lit Shader 内部通过 VertexNormalInputs 结构体和 InitializeVaryings 流程已经自动处理了手性。如果你使用 SurfaceInputTemplates 或手写 Shader,务必自己加上 tangent.w 的修正。
8手性判断的数学直觉
为什么要用 cross(N, T) 而不是 cross(T, N)?这与 Unity 的坐标系约定有关。
Unity 左手坐标系:T × N = −B(左手定则) → N × T = +B(右手定则方向)
Unity 世界空间是左手系,但法线贴图的编码约定是右手系。 因此:
cross(N, T)给出右手系方向的 Btangent.w = +1时,B 不翻转 → 右手系 ✓tangent.w = −1时,B 翻转 → 从左手系"扳回"右手系 ✓

9完整示例:自定义 URP Lit Shader
以下是一个精简但完整的工作流,展示从顶点到片元如何正确使用 TBN:
cs
// -------- Attributes --------
struct Attributes
{
float4 positionOS : POSITION;
float3 normalOS : NORMAL;
float4 tangentOS : TANGENT; // w = 手性
float2 uv : TEXCOORD0;
};
// -------- Varyings --------
struct Varyings
{
float4 positionCS : SV_POSITION;
float2 uv : TEXCOORD0;
float3 normalWS : TEXCOORD1;
float4 tangentWS : TEXCOORD2; // xyz=T, w=手性
};
// -------- Vertex Shader --------
Varyings Vert(Attributes input)
{
Varyings output;
output.positionCS = TransformObjectToHClip(input.positionOS.xyz);
output.uv = input.uv;
output.normalWS = TransformObjectToWorldNormal(input.normalOS);
// 变换切线到世界空间,保留 w 分量
float3 tangentWS = TransformObjectToWorldDir(input.tangentOS.xyz);
output.tangentWS = float4(normalize(tangentWS), input.tangentOS.w);
return output;
}
// -------- Fragment Shader --------
float4 Frag(Varyings input) : SV_Target
{
float3 normalWS = normalize(input.normalWS);
float3 tangentWS = input.tangentWS.xyz;
float handedness = input.tangentWS.w;
// ★ 用 handedness 修正副法线
float3 bitangentWS = handedness * cross(normalWS, tangentWS);
// 构建 TBN 矩阵
float3x3 TBN = float3x3(tangentWS, bitangentWS, normalWS);
// 采样并解码法线贴图
float3 tangentNormal = UnpackNormalScale(
SAMPLE_TEXTURE2D(_BumpMap, sampler_BumpMap, input.uv), _BumpScale);
// 变换到世界空间
float3 worldNormal = normalize(mul(tangentNormal, TBN));
// ... 后续光照计算使用 worldNormal ...
return float4(worldNormal * 0.5 + 0.5, 1.0); // 调试可视化
}
10快速排查清单
当你怀疑镜像 UV 导致法线贴图翻转时,按以下步骤逐项排查:
| # | 检查项 | 预期 |
|---|---|---|
| 1 | Shader 中 tangent.w 是否参与 B 的计算? |
tangent.w * cross(N, T) |
| 2 | Varyings 中 tangentWS 是 float4 还是 float3? |
必须是 float4 以保留 w |
| 3 | 顶点着色器中 tangentOS.w 是否传递到了片元? |
不应丢失 |
| 4 | 模型导入设置中 Import BlendShapes / Tangents 是否正确? |
使用 "Calculate Mikktspace" |
| 5 | 法线贴图编码格式? | 确认 DXT5nm / BC5 解码方式匹配 |
| 6 | 用 worldNormal * 0.5 + 0.5 可视化验证? |
镜像侧颜色应与非镜像侧对称 |
💡 调试技巧
在片元着色器中输出 tangentWS.w 作为颜色:非镜像区域显示白色(+1),镜像区域显示黑色(−1)。如果全是白色,说明模型本身没有镜像 UV,或者导入时手性信息丢失了。
总结
TBN 矩阵的手性问题是 Unity 中法线贴图在镜像 UV 物体上翻转的根因。 tangent.w 是 Unity 为此专门存储的修正信号------它告诉你当前顶点的切线空间 是否因镜像而变成了左手系。只要始终用 tangent.w * cross(N, T) 计算副法线,就能保证 TBN 在所有情况下都是右手系,法线贴图就不会出错。
一句话记住:
副法线 = tangent.w × cross(N, T) → 永远不要省掉 tangent.w