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 并翻转背面法线
相关推荐
mxwin6 小时前
Unity Shader 光线追踪原理与实战案例
unity·游戏引擎
地狱为王14 小时前
Unity使用DPDFNet实现实时语音增强(降噪)
unity·语音增强·降噪·dpdfnet
平行云PVT15 小时前
国产信创实时云渲染解决方案:如何实现数字孪生与三维应用云化部署?
unity·webrtc·unreal engine·实时云渲染·国产信创·摩尔线程·砺算科技
夏炎黄15 小时前
Terrain地形剪裁
unity
小贺儿开发17 小时前
DeepSeek + Codex + MCP:Unity 游戏开发实测
科技·游戏·unity·ai·程序员·编程·deepseek
新的瑞拉公主17 小时前
Cocos plist 粒子一键导入 Unity:Editor 工具实现与参数映射
unity·游戏开发·cocos2d-x·粒子系统·editor工具
淡海水18 小时前
03-02-架构篇-Boehm GC架构解析
unity·架构·c#·gc·boehm
xcLeigh2 天前
Unity基础:Instantiate实例化——在运行时动态创建游戏物体
游戏·unity·游戏引擎·教程·instantiate
流浪打工人2 天前
Unity3d TimeLine中audio track的使用
unity
淡海水3 天前
02-07-原理篇-GC暂停时间的本质
开发语言·unity·c#·gc·暂停时间