法线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 存储的并不是世界空间法线而是切线空间下的偏移量。切线空间以每个顶点为原点建立了一套局部坐标系轴含义通道TTangent沿 UV 的 U 方向—BBitangent沿 UV 的 V 方向—NNormal顶点法线方向—Normal Map 里的 RGB 值(0.5, 0.5, 1.0)在解码后变成(0,0,1)代表不偏转直接朝外。红绿通道编码的是凹凸量。要把它变成世界空间法线需要构建 TBN 矩阵再乘过去// 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 正确带入世界空间光照的最小可用版本// 顶点输入 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并翻转背面法线
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 存储的并不是世界空间法线而是切线空间下的偏移量。切线空间以每个顶点为原点建立了一套局部坐标系轴含义通道TTangent沿 UV 的 U 方向—BBitangent沿 UV 的 V 方向—NNormal顶点法线方向—Normal Map 里的 RGB 值(0.5, 0.5, 1.0)在解码后变成(0,0,1)代表不偏转直接朝外。红绿通道编码的是凹凸量。要把它变成世界空间法线需要构建 TBN 矩阵再乘过去// 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 正确带入世界空间光照的最小可用版本// 顶点输入 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并翻转背面法线