Unity URP 下 TBN 矩阵学习 切线空间、tangent.w 与镜像 UV 的那些坑

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) 给出右手系方向的 B
  • tangent.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 中 tangentWSfloat4 还是 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

相关推荐
Dxy12393102162 小时前
Elasticsearch 8 如何进行二维矩阵向量搜索
大数据·elasticsearch·矩阵
mxwin2 小时前
Unity URP Shader 混合模式完全指南
unity·游戏引擎
南子北游2 小时前
计算机视觉学习(二)图像分类
人工智能·学习·计算机视觉
南子北游2 小时前
计算机视觉学习(一)
人工智能·学习·计算机视觉
白狐_7982 小时前
【深度拆解】2026年数字化学习流:iPad 主动式电容笔的技术底层与选型实测
学习·ios·ipad·电容笔
runningshark2 小时前
【Linux】Virtualbox 中如何给Ubuntu扩容
笔记·学习
白露与泡影2 小时前
从零学习Kafka:ZooKeeper vs KRaft
学习·zookeeper·kafka
日拱一卒的小田2 小时前
ZYNQ学习笔记1-裸机-PS端中断配置、IO配置及PS/PL AXI交互(2-2)
笔记·学习·microsoft
楼田莉子2 小时前
仿muduo的高并发服务器——前置知识讲解和时间轮模块
服务器·开发语言·c++·后端·学习