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提供的“安全网”:UnityObjectToWorldNormal
Unity在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或等效的逆转置矩阵乘法。 |
| 自定义顶点动画后光照错误 | 动画后未重新计算法线 | 在模型空间根据变形后的几何体重新计算法线方向,然后再进行世界空间变换。 |
| 使用法线贴图后效果怪异 | 切线空间basis(TBN矩阵)构建错误或未考虑非等比缩放 | 确保在变换切线(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)重新计算世界副切线(B):float3 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的“使用者”向“理解者”迈进了一步。它背后是线性代数与图形学基本概念的紧密结合。下次当你看到模型在拉伸下光照怪异时,你会立刻意识到:哦,是法线变换矩阵没用好。这种透过现象看本质的能力,正是资深图形程序员与初学者的分水岭。在实际项目中,我个人的习惯是,只要涉及自定义的空间变换,就会立刻条件反射般地思考:这个变换矩阵是否保角?我的法线、切线应该用什么矩阵来变?养成这个思维习惯,能帮你避开渲染中一大类难以察觉的视觉错误。