
1. 项目概述一个被忽视的“经典”陷阱如果你在Unity里做过稍微复杂一点的Shader特别是涉及到自定义光照模型或者顶点/片元着色器那么“法线扭曲”这个问题你大概率遇到过或者即将遇到。表面上看模型在非等比缩放比如Scale是(2, 1, 1)时光照变得诡异高光位置错乱模型表面像是被“拧”过一样。新手的第一反应往往是去检查法线贴图、检查光照计算折腾半天最后发现根源在一个看似人畜无害的矩阵上unity_ObjectToWorld。这个问题之所以“经典”是因为它几乎每个Shader开发者都会踩坑但又因为Unity内置的UnityObjectToWorldNormal等函数帮我们处理了大部分情况导致很多开发者知其然而不知其所以然。一旦你需要自己操作模型空间到世界空间的变换比如处理顶点动画后重新计算法线、或者在某些定制化渲染管线中这个坑就会突然出现让你调试到怀疑人生。今天我们就来彻底拆解这个陷阱不仅告诉你“怎么做”更要讲清楚“为什么”让你下次遇到时能一眼看穿本质。2. 核心原理为什么非等比缩放会让法线“跑偏”要理解这个陷阱我们必须先抛开代码从图形学最基础的数学原理说起。这能帮你建立牢固的直觉以后遇到类似问题都能举一反三。2.1 法线的本质垂直于切平面的向量首先我们明确一点法线Normal不是一个普通的“位置”或“方向”向量。它定义了一个表面的朝向具体来说是垂直于该点处曲面的切平面的向量。在模型导入Unity时每个顶点除了位置Position信息都附带了一个法线向量这个向量是在模型空间Object Space中定义的。当我们说“将物体从模型空间变换到世界空间”时对于顶点的位置Point我们使用的是仿射变换这通常由一个4x4矩阵即unity_ObjectToWorld完成它包含了旋转Rotation、缩放Scale和平移Translation。平移很好理解就是直接加一个偏移量。但对于法线事情就变得微妙了。2.2 缩放变换的“破坏性”为何不能直接乘矩阵想象一个简单的二维例子一个正方形其表面法线朝上向量(0, 1)。现在我们仅沿X轴放大2倍正方形变成了长方形。对于顶点位置直接乘以一个缩放矩阵[2, 0; 0, 1]是正确的。但是对于那个朝上的法线(0, 1)呢如果我们也天真地乘以同一个缩放矩阵会得到(0, 1) —— 看起来没变这是因为我们的例子太特殊了。考虑一个倾斜的平面其模型空间法线为(1, 1)经过同样的非等比缩放矩阵变换后会变成(2, 1)。这个新的向量还垂直于被拉伸后的表面吗通过点积计算就会发现它不再垂直于表面切线了。核心结论直接使用变换顶点位置的矩阵即unity_ObjectToWorld来变换法线在存在非等比缩放Non-uniform Scale的情况下会破坏法线与表面的垂直关系。这是因为缩放变换不是保角的Angle-preserving。2.3 正确的数学工具逆转置矩阵Inverse Transpose那么如何找到一个矩阵M使得变换后的法线n仍然垂直于变换后的表面这需要从切向量的变换推导。设表面在某点处的切线向量为t法线为n满足n·t 0点积为零表示垂直。设变换顶点位置的矩阵为M。变换后的切线t M * t。我们需要找到一个矩阵G来变换法线使得n · t 0其中n G * n。将t和n代入(G * n) · (M * t) 0根据点积与矩阵乘法的关系可以推导出(G * n)^T * (M * t) n^T * G^T * M * t 0。 因为对于原始的垂直关系有n^T * t 0所以要使得上式对任意符合条件的n和t都成立就需要G^T * M I单位矩阵即G (M^{-1})^T。这就是黄金法则用于变换法线的正确矩阵是变换顶点位置矩阵的逆矩阵的转置矩阵Inverse Transpose。在只有旋转和等比缩放的情况下矩阵M是一个正交矩阵旋转乘以一个标量等比缩放此时其逆的转置等于其自身再除以缩放系数的平方。但一旦缩放是非等比的M就不是正交矩阵了(M^{-1})^T与M就截然不同。这就是陷阱的数学根源。3. Unity中的实践与陷阱拆解理解了原理我们再看Unity的具体实现。Unity的Shader内置了很多变量和函数其中就藏着解决方案和陷阱。3.1unity_ObjectToWorld的“三列”本质在Unity Shader中unity_ObjectToWorld是一个float4x4矩阵。它直接将模型空间坐标变换到世界空间。我们可以将其理解为三列基向量和一个平移向量前三列分别代表了模型空间X轴、Y轴、Z轴在世界空间下的指向和缩放长度。第四列代表了模型原点在世界空间中的位置平移。当你写worldPos mul(unity_ObjectToWorld, float4(posOS, 1.0));时正是在进行这个标准的仿射变换。陷阱1直接用于法线变换最直接的坑就是worldNormal mul((float3x3)unity_ObjectToWorld, normalOS);。正如原理部分所述这在非等比缩放下会导致错误。你会得到一个长度和方向都可能出错的世界空间法线。3.2 Unity提供的“安全网”UnityObjectToWorldNormalUnity在UnityCG.cginc等头文件中提供了辅助函数来避免这个坑。最常用的就是UnityObjectToWorldNormal。我们来看一下它的典型实现不同版本可能略有差异但思想一致// 简化后的核心思想 float3 UnityObjectToWorldNormal(float3 normalOS) { // 使用 unity_WorldToObject 矩阵的逆转置部分即3x3子矩阵来变换法线 return normalize(mul(normalOS, (float3x3)unity_WorldToObject)); }注意这里的mul参数顺序是mul(normalOS, matrix)这意味着我们是将法线作为行向量与矩阵相乘这等价于将法线作为列向量与矩阵的转置相乘。而unity_WorldToObject是unity_ObjectToWorld的逆矩阵。所以(float3x3)unity_WorldToObject的转置正好就是(float3x3)unity_ObjectToWorld的逆。这完美符合了我们之前推导的(M^{-1})^T公式。关键点这个函数内部已经帮你处理了逆转置操作并且进行了归一化normalize。归一化很重要因为即使方向正确了缩放也可能改变法线的长度而光照计算通常需要单位向量。3.3 隐藏陷阱何时安全网会失效既然有UnityObjectToWorldNormal为什么我们还会掉坑里因为安全网有覆盖不到的地方。陷阱2自定义顶点变形后的法线这是最常见的实战踩坑场景。比如你在顶点着色器中写了一段波浪动画v.vertex.xyz waveOffset; // 在模型空间修改了顶点位置此时顶点位置已经不再是原始模型位置了。如果你简单地使用UnityObjectToWorldNormal(v.normal)它只会基于原始的、未变形的模型形状和原始的unity_ObjectToWorld矩阵来计算法线变换。但对于变形后的新表面其切平面已经改变因此法线也需要重新计算。正确的做法是在模型空间内根据变形后的新顶点位置重新计算或近似计算新的模型空间法线然后再用UnityObjectToWorldNormal转换到世界空间。对于简单的波形可以通过对波形函数求导来得到新的切线/法线对于复杂变形可能需要传递切线/副切线并重新构造TBN矩阵。陷阱3直接使用unity_WorldToObject的3x3部分有些教程或代码中你会看到这样的写法mul((float3x3)unity_WorldToObject, normalOS)。注意这和UnityObjectToWorldNormal的内部实现mul(normalOS, (float3x3)unity_WorldToObject)是不同的前者是法线作为列向量乘以unity_WorldToObject的3x3部分这实际上是错误的方向。它相当于用M^{-1}而不是(M^{-1})^T去乘法线。只有在等比缩放且无旋转的极端情况下两者结果才偶然相同。陷阱4忽略归一化Normalize即使你用了正确的逆转置矩阵变换得到的法线向量可能也不是单位长度。在非等比缩放下各个轴缩放系数不同变换后的法线长度会发生变化。而像dot(N, L)这样的光照计算要求向量是单位向量否则会导致光照强度错误。因此在片段着色器中使用变换后的世界法线前务必确保其是归一化的。UnityObjectToWorldNormal内置了归一化但如果你是自己构造矩阵变换千万别忘了这一步。陷阱5在Surface Shader中误操作Unity的Surface Shader帮你封装了很多细节。其输入结构Input中的worldNormal字段如果你使用了INTERNAL_DATA宏它会在背后正确计算。但如果你在surf函数之外或通过其他方式手动干预了顶点数据却没有同步更新相关的内部数据如worldNormal或worldTangent也可能导致法线错误。通常在Surface Shader中除非必要应尽量使用其内置的数据流。4. 实战解决方案与代码剖析理论说再多不如一行代码。下面我们针对不同场景给出具体的解决方案和代码片段。4.1 标准情况使用内置函数对于绝大多数不需要顶点变形的标准着色器最佳实践就是无条件使用Unity提供的内置函数。在顶点着色器中计算并传递struct appdata { float4 vertex : POSITION; float3 normal : NORMAL; }; struct v2f { float4 pos : SV_POSITION; float3 worldNormal : TEXCOORD0; }; v2f vert (appdata v) { v2f o; o.pos UnityObjectToClipPos(v.vertex); // 变换顶点到裁剪空间 // 正确且安全的法线变换 o.worldNormal UnityObjectToWorldNormal(v.normal); return o; } fixed4 frag (v2f i) : SV_Target { // 确保在片段着色器中使用单位向量虽然顶点着色器已归一化但插值后长度可能不为1 float3 N normalize(i.worldNormal); // ... 后续光照计算 }这是最省心、最不容易出错的方式。4.2 自定义顶点变形后的法线重计算假设我们在模型空间Y轴方向做了一个正弦波变形v2f vert (appdata v) { v2f o; // 1. 顶点变形模型空间 float wave sin(_Time.y * _WaveSpeed v.vertex.x * _WaveFrequency) * _WaveAmplitude; float4 modifiedVertex v.vertex; modifiedVertex.y wave; // 2. 变换变形后的顶点到裁剪空间 o.pos UnityObjectToClipPos(modifiedVertex); // 3. 重新计算模型空间法线近似 // 对波形函数求导得到Y方向变化相对于X的斜率即切线向量的变化 // dy/dx cos(_Time.y * _WaveSpeed v.vertex.x * _WaveFrequency) * _WaveFrequency * _WaveAmplitude float slope cos(_Time.y * _WaveSpeed v.vertex.x * _WaveFrequency) * _WaveFrequency * _WaveAmplitude; // 原始法线假设为 (0,1,0)。变形后表面在X方向有倾斜所以新法线需要反映这个倾斜。 // 一个简化的近似新法线在X方向的分量与斜率相反并重新归一化。 float3 modifiedNormalOS normalize(float3(-slope, 1.0, 0.0)); // 注意这是一个极度简化的模型仅适用于特定方向的波形。更通用的方法需要切线/副切线。 // 4. 将重新计算的法线变换到世界空间 o.worldNormal UnityObjectToWorldNormal(modifiedNormalOS); return o; }对于更复杂的变形你需要使用顶点着色器中可用的切线TANGENT和副切线通过叉积计算来重新构造模型空间的TBN矩阵然后变换法线。这涉及到更复杂的微积分或数值方法通常用于水面、布料等复杂模拟。4.3 手动构造逆转置矩阵在某些极少数情况下你可能需要自己构造变换矩阵例如使用自定义的变换矩阵而非unity_ObjectToWorld。这时你必须手动处理逆转置。// 假设我们有一个自定义的模型到世界矩阵 myObjectToWorld float4x4 myObjectToWorld ...; // 可能包含非等比缩放 // 手动计算用于法线变换的3x3逆转置矩阵 float3x3 myObjectToWorldNormal transpose((float3x3)inverse(myObjectToWorld)); // 注意在Shader中直接计算inverse(4x4)性能开销较大应尽量避免。 // 更高效的做法是如果myObjectToWorld是从脚本传递的最好在CPU端计算好逆转置矩阵再以uniform传入。 // 变换法线 float3 worldNormal normalize(mul(myObjectToWorldNormal, normalOS));重要提示在Shader中实时计算inverse和transpose对于4x4矩阵是非常昂贵的操作绝对不推荐在每顶点或每片段中执行。正确的做法是在C#脚本中计算好这个逆转置的3x3矩阵然后通过Material.SetMatrix或Shader.SetGlobalMatrix传递给Shader。// C# 脚本端 Matrix4x4 objectToWorld transform.localToWorldMatrix; Matrix4x4 worldToObject transform.worldToLocalMatrix; // 用于法线变换的矩阵是 worldToObject 的转置 (3x3部分) Matrix4x4 normalMatrix worldToObject.transpose; // 或者直接计算逆转置: (objectToWorld.inverse).transpose // 传递给Shader material.SetMatrix(_NormalMatrix, normalMatrix);然后在Shader中使用float3 worldNormal normalize(mul((float3x3)_NormalMatrix, normalOS)); // 或者等价的行向量乘法 // float3 worldNormal normalize(mul(normalOS, (float3x3)_WorldToObject));5. 诊断、调试与常见问题排查当你怀疑法线出了问题可以按照以下步骤进行诊断和调试。5.1 视觉化调试法线最直接的调试方法是将世界空间法线作为颜色输出。因为法线分量范围在[-1, 1]而颜色范围在[0, 1]需要映射fixed4 frag (v2f i) : SV_Target { float3 N normalize(i.worldNormal); // 将法线从[-1,1]映射到[0,1]以便显示 fixed3 color N * 0.5 0.5; return fixed4(color, 1.0); }正常情况一个均匀的球体法线颜色应该从中心到边缘平滑渐变。非等比缩放问题如果一个立方体在X轴缩放2倍你会发现法线颜色在拉伸的面上不均匀或者不同面的交界处颜色不连续这直接表明了法线方向错误。5.2 常见问题速查表问题现象可能原因排查步骤与解决方案光照随视角旋转而“滑动”或闪烁法线未归一化在片段着色器中在片段着色器中添加normalize(i.worldNormal)。注意在顶点着色器归一化后经过线性插值在片段着色器中长度不一定为1。非等比缩放物体光照完全错误高光错位使用了错误的矩阵变换法线如直接用unity_ObjectToWorld检查法线变换代码确保使用了UnityObjectToWorldNormal或等效的逆转置矩阵乘法。自定义顶点动画后光照错误动画后未重新计算法线在模型空间根据变形后的几何体重新计算法线方向然后再进行世界空间变换。使用法线贴图后效果怪异切线空间basisTBN矩阵构建错误或未考虑非等比缩放确保在变换切线Tangent和副切线Bitangent时也使用了正确的逆转置矩阵或使用UnityObjectToWorldDir并与法线保持正交化。通常使用UnityObjectToWorldDir变换切线和副切线并用世界法线叉积重新计算副切线以保证正交。Surface Shader中手动计算的光照与标准光照模型不符可能覆盖或错误设置了SurfaceOutput中的法线在Surface Shader中尽量让Unity自动处理法线。如需自定义请仔细查阅文档确保正确赋值o.Normal。5.3 性能考量与最佳实践归一化的位置在顶点着色器中进行法线变换和归一化然后在片段着色器中再次归一化这是标准做法。顶点着色器归一化减少插值误差片段着色器归一化保证精确性。虽然有些性能开销但对于高质量渲染是必要的。避免动态逆矩阵计算绝对不要在Shader中为每个顶点或片段计算inverse(matrix)。所有逆矩阵、逆转置矩阵都应在CPU端预先计算好。善用内置变量和函数UNITY_MATRIX_IT_MV是模型视图矩阵Model-View的逆转置矩阵常用于将法线变换到观察空间Eye Space。在需要观察空间法线时直接使用它比你自己计算更高效、更安全。对于静态批处理物体静态批处理会合并多个物体的顶点数据其unity_ObjectToWorld矩阵可能不再是单个物体的变换矩阵。但Unity会处理好这一点UnityObjectToWorldNormal等函数在静态批处理下仍然有效。如果你自己手动处理矩阵则需要额外小心。6. 延伸思考切线Tangent与副切线Bitangent的变换法线不是唯一需要特殊对待的向量。当使用法线贴图时我们需要构建一个从切线空间到世界空间的变换矩阵TBN矩阵。这个矩阵由世界空间的切线T、副切线B和法线N组成。切线Tangent它本质上是沿着纹理U方向的方向向量。和法线不同切线是一个方向向量但它的变换规则与法线相同吗不完全是。严格来说切线与表面上的一个方向场相关它的变换应该使用与法线相同的逆转置矩阵以确保它与法线在变换后保持正确的正交关系用于构建正确的切空间。在Unity中我们通常使用UnityObjectToWorldDir(v.tangent.xyz)来变换切线这个函数内部处理了方向向量的变换去除了平移但保留了缩放和旋转的影响。对于非等比缩放为了与正确变换的法线构建正交基有时需要重新正交化。副切线Bitangent通常不直接从模型数据传入而是在Shader中通过法线和切线的叉积计算得出bitangent cross(normal, tangent) * v.tangent.w。这里的v.tangent.w是用于决定副切线方向即纹理V方向的符号。关键点在变换到世界空间后更稳健的做法是使用变换后的世界法线N和世界切线T重新计算世界副切线Bfloat3 worldBitangent cross(worldNormal, worldTangent) * tangent.w;。这能确保在世界空间中TBN矩阵仍然是正交的即使原来的变换包含了非等比缩放。因此一个健壮的、支持非等比缩放的法线贴图TBN矩阵构建代码片段如下v2f vert (appdata_tan v) { // 使用包含切线的appdata结构 v2f o; o.pos UnityObjectToClipPos(v.vertex); o.worldNormal UnityObjectToWorldNormal(v.normal); o.worldTangent UnityObjectToWorldDir(v.tangent.xyz); // 变换切线 o.tangentSign v.tangent.w; // 存储副切线方向符号 return o; } fixed4 frag (v2f i) : SV_Target { float3 N normalize(i.worldNormal); float3 T normalize(i.worldTangent); // 重新计算副切线确保与法线、切线正交 float3 B cross(N, T) * i.tangentSign; // 构建从切线空间到世界空间的3x3矩阵 float3x3 TBN float3x3(T, B, N); // 从法线贴图中采样切线空间法线 float3 normalTS UnpackNormal(tex2D(_BumpMap, i.uv)); // 转换到世界空间 float3 worldNormalFromMap mul(TBN, normalTS); // 使用 worldNormalFromMap 进行光照计算 }这个陷阱的解决标志着你从Shader的“使用者”向“理解者”迈进了一步。它背后是线性代数与图形学基本概念的紧密结合。下次当你看到模型在拉伸下光照怪异时你会立刻意识到哦是法线变换矩阵没用好。这种透过现象看本质的能力正是资深图形程序员与初学者的分水岭。在实际项目中我个人的习惯是只要涉及自定义的空间变换就会立刻条件反射般地思考这个变换矩阵是否保角我的法线、切线应该用什么矩阵来变养成这个思维习惯能帮你避开渲染中一大类难以察觉的视觉错误。