法线(Normal)是渲染里最容易被误解的向量。位置可以随便乘矩阵,方向向量也大差不差,但法线一旦照搬这套做法,灯光就会出错------高光跑偏、边缘光渗进内侧、Normal Map看起来像在发光而不是凹凸。
这篇文章从根本上讲清楚为什么法线特殊,以及在 URP ShaderLab 里应该怎么写。
一、向量有三种,变换规则各不同
在渲染管线里,空间里的"量"分三类:
- 点(Position):有位置,受平移影响
- 方向向量(Direction):无位置,受旋转 / 缩放影响,不受平移影响
- 法线向量(Normal) :特殊的方向向量,与面垂直,缩放规则和普通向量相反
三种空间量的变换规则

二、为什么法线不能直接用 M 变换?
来看一个具体场景:把一个球沿 X 轴缩放 2 倍(非均匀缩放)。表面上有一条切线 T 和法线 N ,它们的关系是 N · T = 0。
变换后切线变成 T' = M·T 。如果法线也直接用 M 变换,得到 N' = M·N,则:
N' · T' = (M·N)ᵀ · (M·T) = Nᵀ · Mᵀ·M · T
只有当 Mᵀ·M = I(即 M 是正交矩阵)时,这个点积才等于零。一旦有不均匀缩放,Mᵀ·M ≠ I,法线就"歪"了。
正确做法是用 (Mᵀ)⁻¹ 变换法线,这样:
N'_correct · T' = ((Mᵀ)⁻¹N)ᵀ · (M·T) = Nᵀ · (Mᵀ)⁻ᵀ·M · T = Nᵀ·N·T = 0 ✓
下图展示非均匀缩放时,直接用 M 与用 (Mᵀ)⁻¹ 的结果差异:

三、法线所在的几个坐标空间
URP 的渲染管线中,法线会在多个空间里流转。理解每个空间的含义是写对 Shader 的前提。

四、切线空间(Tangent Space)的特殊地位
Normal Map 存储的并不是世界空间法线,而是切线空间下的偏移量。切线空间以每个顶点为原点,建立了一套局部坐标系:
| 轴 | 含义 | 通道 |
|---|---|---|
| T(Tangent) | 沿 UV 的 U 方向 | --- |
| B(Bitangent) | 沿 UV 的 V 方向 | --- |
| N(Normal) | 顶点法线方向 | --- |
Normal Map 里的 RGB 值 (0.5, 0.5, 1.0) 在解码后变成 (0,0,1),代表"不偏转,直接朝外"。红绿通道编码的是凹凸量。
要把它变成世界空间法线,需要构建 TBN 矩阵,再乘过去:
cs
// URP / HLSL 中的正确写法
float3 normalTS = UnpackNormal(tex2D(_NormalMap, uv)); // 从切线空间解码
// 顶点着色器中传入 TBN(已用 (M^T)^{-1} 变换)
float3 T = TransformObjectToWorldDir(tangentOS.xyz);
float3 N = TransformObjectToWorldNormal(normalOS);
float3 B = cross(N, T) * tangentOS.w; // tangentOS.w 决定 bitangent 方向
float3x3 TBN = float3x3(T, B, N);
float3 normalWS = mul(normalTS, TBN); // 切线 → 世界
五、URP 内置函数一览
Unity URP 的 Core.hlsl 已经封装了正确的法线变换逻辑,不需要手写转置逆矩阵。下面是常用函数:

六、一个完整的 URP Custom Lit Shader 片段
下面是一个把 Normal Map 正确带入世界空间光照的最小可用版本:
cs
// 顶点输入
struct Attributes {
float4 positionOS : POSITION;
float3 normalOS : NORMAL;
float4 tangentOS : TANGENT; // w 分量决定 bitangent 手性
float2 uv : TEXCOORD0;
};
// 顶点到片元
struct Varyings {
float4 positionHCS : SV_POSITION;
float2 uv : TEXCOORD0;
float3 normalWS : TEXCOORD1;
float3 tangentWS : TEXCOORD2;
float3 bitangentWS : TEXCOORD3;
float3 positionWS : TEXCOORD4;
};
Varyings vert(Attributes IN) {
Varyings OUT;
OUT.positionHCS = TransformObjectToHClip(IN.positionOS.xyz);
OUT.positionWS = TransformObjectToWorld(IN.positionOS.xyz);
// 法线和切线都必须用 (M^T)^{-1},URP 内置函数已封装
OUT.normalWS = TransformObjectToWorldNormal(IN.normalOS);
OUT.tangentWS = TransformObjectToWorldDir(IN.tangentOS.xyz);
// bitangent = cross(N, T) * tangent.w(手性修正)
OUT.bitangentWS = cross(OUT.normalWS, OUT.tangentWS) * IN.tangentOS.w;
OUT.uv = IN.uv;
return OUT;
}
half4 frag(Varyings IN) : SV_Target {
// 解码 Normal Map
float4 packedNormal = SAMPLE_TEXTURE2D(_NormalMap, sampler_NormalMap, IN.uv);
float3 normalTS = UnpackNormalScale(packedNormal, _NormalScale);
// 切线空间 → 世界空间
float3x3 TBN = float3x3(IN.tangentWS, IN.bitangentWS, IN.normalWS);
float3 normalWS = normalize(mul(normalTS, TBN));
// 世界空间光照
Light light = GetMainLight();
float NdotL = saturate(dot(normalWS, light.direction));
return half4(NdotL.xxx, 1);
}
七、常见错误与定位方法

八、一句话总结
法线的特殊性源于它的几何约束 ------它必须始终垂直于表面。这个约束在非均匀缩放下无法通过普通矩阵乘法保持,必须用转置逆矩阵 (Mᵀ)⁻¹ 变换。URP 的内置函数已经处理好了这件事,你需要做的是:
- 顶点着色器里 :用
TransformObjectToWorldNormal()而不是手动乘矩阵 - 有 Normal Map 时 :带
TANGENT语义,在顶点阶段构建 TBN,在片元阶段mul(normalTS, TBN)转换 - 片元着色器里 :
normalize()是必须的,插值会破坏单位长度 - 双面材质 :检查
VFACE并翻转背面法线