Unity Shader URP:法线在空间变换上的特殊性

法线(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 的内置函数已经处理好了这件事,你需要做的是:

  1. 顶点着色器里 :用 TransformObjectToWorldNormal() 而不是手动乘矩阵
  2. 有 Normal Map 时 :带 TANGENT 语义,在顶点阶段构建 TBN,在片元阶段 mul(normalTS, TBN) 转换
  3. 片元着色器里normalize() 是必须的,插值会破坏单位长度
  4. 双面材质 :检查 VFACE 并翻转背面法线
相关推荐
nnsix2 小时前
Unity 动态批处理、静态批处理、GPU Instaning、SRP Batcher 笔记
笔记·unity·单一职责原则
charlee442 小时前
Unity在安卓端如何调试输出信息
android·unity·adb·游戏引擎·真机调试
TCW11214 小时前
Minetest游戏引擎源代码解析
游戏引擎
_Athie4 小时前
【开发工具】自动创建项目文件夹结构
unity·编辑器
auccy1 天前
Unity Sprite 添加法线贴图
unity·贴图·normal
一锅炖出任易仙1 天前
创梦汤锅学习日记day32
学习·ai·游戏引擎
mxwin1 天前
次世代角色 PBR 贴图制作 + Unity URP 接入 极简流程图
unity·流程图·贴图·shader
mxwin1 天前
Unity URP 法线贴图如何生成 用什么工具创建
unity·游戏引擎·贴图