Unity URP 切线空间详解

深入理解 TBN 矩阵、法线贴图与着色器实现

一、什么是切线空间(Tangent Space)

在 3D 图形渲染中,**切线空间(Tangent Space)**是一个相对于模型表面的局部坐标系。 与世界空间(World Space)或物体空间(Object Space)不同,切线空间的原点位于每个顶点的表面上, 其三个基向量会随着表面的弯曲而"贴合"在模型上。

切线空间的核心价值在于:让法线贴图(Normal Map)可以通用化。 一张在平面上的法线贴图,可以直接应用到任意弯曲的表面上,而无需为每个模型单独制作法线贴图。

**💡 核心概念:**切线空间是一个"跟随表面"的坐标系。每个顶点都有自己的切线空间, 法线贴图中存储的 RGB 值,实际上是在这个局部空间中的 (x, y, z) 方向偏移量。

二、TBN 矩阵的构成

TBN 矩阵由三个相互垂直的向量组成,将向量在切线空间与世界/物体空间之间进行转换:

向量 英文 方向 作用
T Tangent(切线) 沿表面的水平方向(UV 的 U 方向) 定义表面的"左右"方向
B Bitangent / Binormal(副切线) 沿表面的垂直方向(UV 的 V 方向) 定义表面的"上下"方向
N Normal(法线) 垂直于表面向外 定义表面的"凹凸"方向

2.1 法线(Normal)

法线向量 N 垂直于表面,通常由模型顶点数据直接提供(mesh.normals)。 在 Unity 中,每个顶点都携带一个法线向量,用于光照计算的基础方向。

2.2 切线(Tangent)

切线向量 T 沿着表面的 UV 坐标的 U 方向。 它告诉着色器:在模型表面上,"向右"是哪个方向。 Unity 的 Mesh.RecalculateTangents() 可以自动计算切线。

2.3 副切线(Bitangent)

副切线 B 由法线和切线的叉积得到:B = N × T。 它沿着 UV 的 V 方向,与 T 和 N 共同构成正交坐标系。

数学关系:

T · N = 0(切线与法线垂直)

B · N = 0(副切线与法线垂直)

B · T = 0(副切线与切线垂直)

|T| = |B| = |N| = 1(单位向量)

三、URP 中的切线空间实现

在 Unity 的 URP(Universal Render Pipeline)中,切线空间的计算和使用已经高度封装。 核心 Shader 变量和函数位于以下文件中:

  • Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl
  • Packages/com.unity.render-pipelines.core/ShaderLibrary/CommonMaterial.hlsl

3.1 顶点着色器中的 TBN 计算

在 URP 的 VertexShader 中,通常这样计算 TBN 矩阵:

cs 复制代码
// 在顶点着色器中
v2f vert(appdata v)
{
    v2f o;

    // 基本的 MVP 变换
    o.positionCS = TransformObjectToHClip(v.vertex.xyz);

    // 法线(世界空间)
    o.normalWS = TransformObjectToWorldNormal(v.normal);

    // 切线(世界空间)
    o.tangentWS = TransformObjectToWorldDir(v.tangent.xyz);

    // 副切线(世界空间)
    // w 分量存储手性信息(用于镜像 UV)
    o.bitangentWS = cross(o.normalWS, o.tangentWS) * v.tangent.w;

    return o;
}

⚠️ 注意: v.tangent.w 存储了副切线的方向符号(+1 或 -1), 用于处理 UV 镜像导致的坐标系手性问题。忽略它会导致法线贴图在镜像模型上出现错误。

3.2 片元着色器中的法线贴图解码

在片元着色器中,将法线贴图的采样结果从切线空间转换到世界空间:

cs 复制代码
// 在片元着色器中
half4 frag(v2f i) : SV_Target
{
    // 采样法线贴图
    half4 normalMap = SAMPLE_TEXTURE2D(_NormalMap, sampler_NormalMap, i.uv);

    // 解码法线贴图(URP 工具函数)
    // 法线贴图中的值范围是 [0, 1],需要映射到 [-1, 1]
    half3 normalTS = UnpackNormal(normalMap);

    // 构建 TBN 矩阵(世界空间)
    half3 N = normalize(i.normalWS);
    half3 T = normalize(i.tangentWS);
    half3 B = normalize(i.bitangentWS);

    // 将法线从切线空间转换到世界空间
    half3 normalWS = normalize(
        T * normalTS.x +
        B * normalTS.y +
        N * normalTS.z
    );

    // 使用世界空间法线进行光照计算
    // ...
}

3.3 URP 内置的 TBN 辅助函数

URP 提供了更简洁的实现方式,使用内置宏:

cs 复制代码
// 方法一:使用 URP 内置的 TransformTangentToWorld
half3 normalWS = TransformTangentToWorld(
    normalTS,
    half3x3(i.tangentWS, i.bitangentWS, i.normalWS)
);

// 方法二:使用法线贴图助手(推荐)
// 在 Properties 中声明 _NormalMap 和 _BumpScale
half3 normalWS = NormalizeNormalPerPixel(
    TransformTangentToWorld(
        normalTS,
        half3x3(i.tangentWS, i.bitangentWS, i.normalWS)
    )
);

四、完整的 URP Shader 代码示例

以下是一个完整的 URP Shader 示例,展示如何正确使用切线空间进行法线贴图计算:

cs 复制代码
Shader "Custom/URPTangentSpaceExample"
{
    Properties
    {
        _BaseMap ("Albedo", 2D) = "white" {}
        _NormalMap ("Normal Map", 2D) = "bump" {}
        _BumpScale ("Normal Scale", Range(0, 2)) = 1.0
        _BaseColor ("Color", Color) = (1,1,1,1)
    }

    SubShader
    {
        Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" }

        Pass
        {
            HLSLPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
            #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl"

            struct appdata
            {
                float4 vertex   : POSITION;
                float3 normal   : NORMAL;
                float4 tangent  : TANGENT;
                float2 uv       : TEXCOORD0;
            };

            struct v2f
            {
                float4 positionCS   : SV_POSITION;
                float2 uv           : TEXCOORD0;
                float3 normalWS     : TEXCOORD1;
                float3 tangentWS    : TEXCOORD2;
                float3 bitangentWS  : TEXCOORD3;
                float3 positionWS   : TEXCOORD4;
            };

            TEXTURE2D(_BaseMap);
            SAMPLER(sampler_BaseMap);
            TEXTURE2D(_NormalMap);
            SAMPLER(sampler_NormalMap);

            CBUFFER_START(UnityPerMaterial)
                float4 _BaseMap_ST;
                float4 _BaseColor;
                float _BumpScale;
            CBUFFER_END

            v2f vert(appdata v)
            {
                v2f o;
                o.positionCS = TransformObjectToHClip(v.vertex.xyz);
                o.positionWS = TransformObjectToWorld(v.vertex.xyz);
                o.uv = TRANSFORM_TEX(v.uv, _BaseMap);

                // 计算世界空间 TBN
                o.normalWS = TransformObjectToWorldNormal(v.normal);
                o.tangentWS = TransformObjectToWorldDir(v.tangent.xyz);
                o.bitangentWS = cross(o.normalWS, o.tangentWS) * v.tangent.w;

                return o;
            }

            half4 frag(v2f i) : SV_Target
            {
                // 采样法线贴图
                half4 normalSample = SAMPLE_TEXTURE2D(_NormalMap, sampler_NormalMap, i.uv);
                half3 normalTS = UnpackNormal(normalSample);
                normalTS.xy *= _BumpScale;

                // 构建 TBN 矩阵并转换到世界空间
                half3 N = normalize(i.normalWS);
                half3 T = normalize(i.tangentWS);
                half3 B = normalize(i.bitangentWS);
                half3 normalWS = normalize(mul(normalTS, half3x3(T, B, N)));

                // 采样 Albedo
                half4 albedo = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, i.uv) * _BaseColor;

                // 光照计算
                Light mainLight = GetMainLight();
                half NdotL = max(0, dot(normalWS, mainLight.direction));
                half3 lighting = mainLight.color * NdotL;

                // 最终颜色
                half3 color = albedo.rgb * lighting + albedo.rgb * 0.1; // 加上环境光

                return half4(color, albedo.a);
            }
            ENDHLSL
        }
    }
}

五、常见问题与注意事项

❌ 常见错误
  • 忘记对法线进行 normalize
  • 忽略 tangent.w 手性符号
  • 在片元着色器中对未归一化的 TBN 向量进行变换
  • 法线贴图未设置为"Normal Map"类型
  • 在 URP 中使用了内置管线的 _WorldSpaceLightPos0
✅ 最佳实践
  • 始终在片元着色器中重新归一化法线
  • 使用 UnpackNormal() 解码法线贴图
  • 使用 URP 的 GetMainLight() 获取光源
  • 使用 TransformTangentToWorld() 辅助函数
  • 在 Shader 面板中正确设置法线贴图纹理类型

5.1 为什么需要重新归一化?

在光栅化过程中,顶点着色器输出的向量在三角面片上进行插值后,长度不再为 1。 因此,在片元着色器中必须重新对法线进行归一化:

cs 复制代码
// 正确做法
half3 normalWS = normalize(TransformTangentToWorld(normalTS, half3x3(T, B, N)));

5.2 切线空间的左右手坐标系

Unity 使用左手坐标系 。 切线空间通常也是左手系(T 向右,B 向上,N 向外)。 但当模型的 UV 出现镜像时,tangent.w 会变为 -1, 此时副切线的方向需要翻转以保持正交性。

六、总结

切线空间是 Unity URP 着色器开发中的核心概念,理解它的构成对于正确实现法线贴图至关重要:

要点 说明
TBN 矩阵 由切线、副切线、法线组成的正交矩阵,用于空间转换
法线贴图存储 在切线空间中存储表面凹凸信息,实现贴图复用
URP 实现 使用 TransformObjectToWorldDirUnpackNormalTransformTangentToWorld
注意事项 归一化、手性符号、纹理类型设置

**🎯 核心要点:**切线空间让法线贴图"跟随表面"。 TBN 矩阵是实现这一点的数学工具,将切线空间的法线方向转换到世界空间, 从而实现正确的光照计算。

相关推荐
caimouse7 小时前
Godot Engine 最新版官方文档(简体中文完整翻译 & 精简梳理)
游戏引擎·godot
huizhixue-IT9 小时前
Superpowers 游戏引擎从零开发实战指南
游戏引擎
做cv的小昊21 小时前
计算机图形学:【Games101】学习笔记08——光线追踪(辐射度量学、渲染方程与全局光照、蒙特卡洛积分与路径追踪)
图像处理·笔记·学习·计算机视觉·游戏引擎·图形渲染·概率论
玖玥拾21 小时前
Cocos学习笔记:序列化、配置文件与数据驱动
游戏引擎·cocos2d
RReality1 天前
【Unity UGUI】血条 / 进度条(HP Bar)
ui·unity·游戏引擎·图形渲染
mxwin1 天前
Unity Shader URP:法线如何进行光照计算
unity·游戏引擎·shader
郝学胜-神的一滴1 天前
中级OpenGL教程 009:用环境光告别模型死黑
前端·c++·unity·godot·图形渲染·opengl·unreal
一锅炖出任易仙1 天前
创梦汤锅学习日记day30
学习·ai·ue5·游戏引擎
mxwin2 天前
Unity URP 中的法线生成完全指南
unity·游戏引擎