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 并翻转背面法线
相关推荐
叶帆17 天前
【YFIOs】用C#开发硬件之设备上云
开发语言·unity·c#
久数君17 天前
AI三维建模工具“造形家”:地理场景三维化的高效解决方案
unity·glb·ai算法·ai三维建模工具·地图框选·造形家·城市建筑模型
会思考的猴子18 天前
Unity VFX 属性 Postion 和 TargetPostion
unity
hai31524754318 天前
九章编程法 · 猜数字游戏 (GW-BASIC 重构版) *
人工智能·microsoft·游戏引擎·游戏程序
心前阳光18 天前
Unity资源导入之自动化资源导入
unity·自动化·游戏引擎
心前阳光18 天前
Unity之2021.3.45f2c1发布安卓程序遇到的问题
android·unity·游戏引擎
纪纯18 天前
PicoVR Unity Integration SDK 3.4 常用交互API
unity·游戏引擎·vr·pico
龙智DevSecOps解决方案18 天前
3A 游戏优化技术栈:如何打通引擎级分析工具与 DevOps 持续集成管线?
unity·性能优化·游戏开发·技术美术·perforce·unrealengine
葛兰岱尔18 天前
从 SolidWorks 到 Three.js,从 Inventor 到 Unity——制造业CAD模型“几何-语义一体化“转换,不再是天方夜谭!
开发语言·javascript·unity
鼎艺创新科技18 天前
三维电子沙盘中OSGB倾斜摄影数据的加载与渲染
游戏引擎·cocos2d