Unity 里做 Shader 开发,只要聊到法线变换,面试官大概率会问一句:为什么需要逆转置矩阵?我在工作里第一次真正挨这个问题的“打”,是做破碎掉落的效果——把一整块模型切成几十个碎块,每个碎块在脚本里随机设置了 scale。结果一运行,碎块表面的光照惨不忍睹:有的面亮得发白,有的面出现大片黑斑,旋转镜头时高光还在模型表面乱窜。那会儿我以为是法线贴图采样出了问题,把 uv、法线贴图、光照方向一个个排查过去,最后才发现问题出在变换法线的那行代码上。
如果你也遇到过类似的情况——模型做非等比缩放之后,光照和阴影变得很奇怪,十有八九就是法线变换用错了矩阵。这篇文章我不打算只给结论,而是把“为什么需要逆转置矩阵”这件事从几何直觉、数学推导、Unity 内置实现到实际排查,完整捋一遍。内容适合刚接触 Shader 的入门读者,也适合那些面试前想把这个知识点彻底吃透的人。
1. 先从一个踩坑现场说起:法线变换为什么会把光照搞坏
1.1 碎块、压扁的箱子和“看着没问题”的变换
那次破碎效果的 bug 最后定位到一行代码:
o.worldNormal = normalize(mul((float3x3)unity_ObjectToWorld, v.normal));这行代码表面看没什么问题:顶点是这么从对象空间变到世界空间的,法线也照葫芦画瓢,用同一个矩阵乘一下,然后再归一化。这个做法在旋转和等比缩放下完全正确,所以很多项目跑了一年半载也没出事。但一旦出现非等比缩放,比如碎块的 scale 是 (1.2, 0.6, 2.0),法线方向就错了。
为什么错了?因为顶点和法线在变换里的“身份”不一样。顶点是模型上的一个点,它跟着模型的旋转、缩放、平移走,所以要用完整的模型矩阵。法线虽然名字里带个“线”,但它真正描述的并不是一条线的方向,而是一个平面的朝向。你在模型表面取一个极小极小的平面,法线就是这个平面的法向。当你把一个模型沿着 Y 轴压扁一半时,顶点确实跟着压扁了,但原本朝上的面,它的法线不应该也跟着“变歪”才对——它应该仍然垂直于那个被压扁后的表面。
用直接乘模型矩阵的方式变换法线,等于把法线也当成了一个普通的、固定长度的方向向量来处理。这在等比缩放下没有区别,在非等比缩放下就是灾难。
1.2 我一开始的迷惑:为什么有些面看起来还行
还有一个现象值得单独拿出来说。出了 bug 之后,我第一反应是整个模型都坏了,但实际观察下来,并不是所有面都明显异常。比如一个箱子,顶面和底面看起来问题不大,前后左右四个面错得比较厉害;有些碎块只做了旋转,完全没缩放,光照完全正常;有些碎块是等比缩小,也只是“整体变暗了一点”。
这个现象背后其实藏着法线变换的一个关键规律:旋转不会破坏法线方向,等比缩放只会缩短或拉长法线长度(归一化之后没影响),唯独非等比缩放会把法线“掰弯”。顶面为什么看起来还行?因为箱子的顶面正好平行于世界坐标平面,即使乘错了矩阵,方向上的偏差也恰好不明显;侧面因为缩放比例差异大,错误被放大了。
所以排查这类问题时,如果只盯着个别面看,很难看出规律。正确做法是把法线方向直接可视化出来,比如用颜色显示法线的 XYZ 分量,就能一眼看出哪些面的法线方向错了、错在哪个方向。
1.3 这个知识点到底谁在用
法线变换并不是只在“物体级缩放”这种场景里出现。凡是做过地形渲染、程序化生成网格、动画骨骼蒙皮、粒子特效里的块状模型,都会遇到。尤其是程序化生成的网格,开发者经常在代码里直接给网格赋值顶点坐标和法线,如果后面 Transform 上挂了非等比缩放的父物体,所有法线立刻集体翻车。
还有阴影。阴影计算里有一个叫 normal bias 的东西,用来避免阴影痤疮,它要把顶点沿着法线方向“顶”一下。如果法线方向本身是错的,阴影就会出现奇怪的条纹、抖动甚至穿透。我在项目里排查过一次阴影边缘闪烁的问题,最后追踪到根源竟然也是某个工具猫在历史版本里用了错误的法线变换。所以别觉得这只是“理论上的数学题”,它是实实在在会咬人的坑。
2. 法线不是普通向量:从切平面理解为什么要“特殊对待”
2.1 用一张纸和一支笔理解法线的本质
想彻底搞明白法线变换,不能把法线理解成“一条有方向的线”,要理解成“一个平面的属性”。我经常用一张纸来类比:在模型表面那个极小区域里,假想有一块小纸片,纸片所在的面就是切平面,法线是垂直于纸片的那根轴。
当你对模型做变换时,纸片上的点会跟着矩阵走,纸片的方向也会改变。变换后的法线,应该垂直于变换后的纸片,而不是简单的“把原来的法线方向转一下”。换句话说,法线跟切线之间存在一个垂直约束,变换法线时必须保持这个约束。
什么叫切线?假设 T 是模型表面上某一点处的切线方向,N 是该点法线,那么 N 和 T 垂直。变换后,切线 T 变成 M·T(这里 M 是模型变换矩阵的旋转缩放部分)。法线我们想用一个变换矩阵 G 来变换,变成 G·N。为了光照计算正确,变换后的法线必须仍然垂直于变换后的切线。这一个约束条件,就是推导整个逆转置矩阵的依据。
2.2 2D 例子:长方形被压扁之后法线怎么走
我们简化到二维,看一个矩形。矩形的上边是一条水平线段,它的法线在二维里就是竖直方向。现在把整个矩形沿着水平方向拉长两倍,竖直方向保持不变,原来竖直的边仍然竖直,原来水平的边仍然水平。这个例子里,法线似乎不用变。
但如果我们斜着压呢?把矩形沿着 45 度方向拉伸,原来水平的边被拉斜了,这条边的法线方向就必须跟着改变,否则它就不垂直于这条边了。在二维里,你可以直接画图算出来新旧法线的差别:新法线不是简单地对旧法线做同样的拉伸,而是要做“反向压缩”——拉伸越厉害的方向,法线分量反而要缩小。
这就是为什么法线变换和顶点变换完全不同:顶点要跟着模型的变形一起“拉长”,法线却要“抵消”变形的效果,保持与切平面的垂直。
2.3 为什么平时直接用模型矩阵乘也没出大事
这可能是很多人的真实困惑:我也没写过什么逆转置矩阵,项目不也跑得好好的?
原因有两个。一是大多数模型从来没有非等比缩放。美术在建模软件里导出的模型,放到场景里最多旋转和平移,缩放基本都是等比缩放,哪怕有缩放,也只是整体放大缩小,比如 0.8 倍、1.2 倍。等比缩放下,法线直接乘模型矩阵,得到的方向和用逆转置矩阵计算的方向一模一样,只是长度差一个常数,反正后面都要 normalize,结果完全没区别。
二是即使有非等比缩放,如果缩放比例相差不大,视觉上错误也不容易被注意到。1.05 和 1.1 的差别藏在灯光和相机运动里,很难肉眼发现。只有缩放比例拉得很大,或者刚好做了“压扁”“拉长”这种明显变形,错误才会暴露出来。
所以这个 bug 特别阴险,它平时潜伏在代码里,等美术突然把某个物体压扁,或者程序化生成一个非等比缩放的网格,就突然跳出来咬你一口。这也是我建议所有 Shader 里统一切到 UnityObjectToWorldNormal 的原因——不是为了炫技,是为了彻底消除这一类隐患。
3. 从垂直约束出发,一步步推出逆转置矩阵
3.1 推导过程:只要三步,没有高深数学
现在来做数学推导。设 M 是模型变换矩阵中的 3x3 旋转缩放部分,用来变换顶点位置。设模型表面上某一点的切线方向是 T,法线方向是 N,在对象空间里它们垂直:
N · T = 0
变换之后,切线变成 T' = M·T。假设我们用一个还不知道长什么样的矩阵 G 来变换法线,得到 N' = G·N。为了保证变换后光照计算正确,我们必须要求:
N' · T' = 0
把 T' 和 N' 代进去:
(G·N) · (M·T) = 0
这个式子用矩阵语言写就是:
Nᵀ · Gᵀ · M · T = 0
注意,原始的 Nᵀ · T = 0。要让上面这个等式对任意一组垂直的 N 和 T 都成立,唯一的方式是让中间的 Gᵀ·M 变成一个单位矩阵的倍数。也就是说:
Gᵀ · M = λI
两边取转置,再整理一下:
G = λ · (Mᵀ)⁻¹ = λ · (M⁻¹)ᵀ
λ 是一个任意常数。因为法线在后面总会做归一化,λ 的具体值不影响最终方向,所以通常直接写成:
G = (M⁻¹)ᵀ
这就是“逆转置矩阵”的来历:先对 M 求逆,再转置。
3.2 三种矩阵特殊情形对照:旋转、等比缩放、非等比缩放
这个 G 在不同情况下和 M 的关系很值得列个表,理解了这张表,就理解了整个问题的一半。
| 变换类型 | M 的性质 | 逆转置 G | 和 M 的关系 | 能否直接用 M |
|---|---|---|---|---|
| 纯旋转 | 正交矩阵,M⁻¹ = Mᵀ | (M⁻¹)ᵀ = M | 完全相等 | 可以 |
| 平移 | 方向变换可忽略平移 | 与平移无关 | 只需 3x3 部分 | 可以 |
| 等比缩放 | M = sR | G = (1/s)R | 只差一个常数因子 | 归一化后等价 |
| 非等比缩放 | M = diag(sx,sy,sz)·R | G = R·diag(1/sx,1/sy,1/sz) | 方向和长度都不同 | 不可以 |
表格里最重要的一行是最后一行。非等比缩放下,原来乘 2 的轴,法线对应分量要除 2,这个“一乘一除”的差异,用眼睛看就是法线方向被掰歪了。
顺便提一个特殊情况:负缩放。如果你的 Transform 某个轴是负数,比如 scale(-1, 1, 1),这其实是一种镜像。逆转置矩阵在数学上仍然能保证法线垂直于切平面,但矩阵的行列式为负数,手性发生了翻转。这种时候,法线方向在数学上“正确”,在视觉上却是反的——它指向了表面内部。Unity 的 UnityObjectToWorldNormal 没有内置处理负缩放的逻辑,遇到镜像模型需要自己判断行列式,手动把法线翻转一次。这个问题我会在第 4 节专门展开。
3.3 为什么推导时只取旋转缩放部分,平移去哪了
很多刚接触这个推导的人会问:模型矩阵是 4x4 的,法线是 float3,怎么乘?
关键点在于:法线是方向向量,它的齐次坐标 w 是 0。方向向量不随平移变化,平移分量对法线没有任何影响。所以无论是 M 还是 G,我们只需要取左上角 3x3 的旋转缩放部分。这也是为什么 Unity 源码里总是看到(float3x3)unity_ObjectToWorld这种强转——就是为了明确告诉编译器,我们不要平移分量,把法线当方向向量处理。
这一点在面试时也是一个隐形考点。如果你在推导时用的是 4x4 矩阵求逆,最后还要解释清楚:逆矩阵的平移分量对方向向量没意义,因为 w=0 的向量乘以平移列后结果永远是 0。从实现角度说,取 3x3 部分求逆比 4x4 求逆便宜得多,Unity 里甚至直接从 UnityObjectToWorld 的逆矩阵里取 3x3 部分,连“求逆”这个动作都省了。
4. Unity 内置实现解读:UnityObjectToWorldNormal 到底做了什么
4.1 源码逐行拆解
Unity 在 UnityCG.cginc 里提供了现成的函数,绝大多数情况下我们应该直接用,而不是自己手写。它的实现大概是这样的:
inline float3 UnityObjectToWorldNormal( in float3 normal ) { #ifdef UNITY_ASSUME_UNIFORM_SCALING return UnityObjectToWorldDir(normal); #else // mul(IT_M, norm) => mul(norm, I_M) => {dot(norm, I_M.col0), dot(norm, I_M.col1), dot(norm, I_M.col2)} return normalize(mul(normal, (float3x3)unity_WorldToObject)); #endif }第一次看到这段代码的人往往会愣一下:为什么不是用 unity_ObjectToWorld,而是用 unity_WorldToObject?为什么是 normal 放在左边乘?
这两个问题,正好是理解 Unity 法线变换实现的关键。
unity_ObjectToWorld 是把点从对象空间变换到世界空间的矩阵,它的旋转缩放部分是 M。而 unity_WorldToObject 是它的逆矩阵,旋转缩放部分就是 M⁻¹。我们需要的逆转置矩阵是 (M⁻¹)ᵀ。现在问题是:怎么用 HLSL 的 mul 实现 (M⁻¹)ᵀ 乘以 normal?
这里用到一个矩阵转置的小技巧。HLSL 里mul(rowVector, A)的结果等于 Aᵀ 乘以列向量 rowVectorᵀ。也就是说:
mul(normal, unity_WorldToObject)等价于(unity_WorldToObject)ᵀ · normal
由于 unity_WorldToObject 是 M⁻¹,那么它的转置就是 (M⁻¹)ᵀ,这正是我们要的逆转置矩阵。所以一行代码同时完成了“取逆”和“转置”两个动作,而且是从 Unity 引擎预计算好的逆矩阵里直接取,不需要在 Shader 里做任何矩阵求逆运算。
4.2 UNITY_ASSUME_UNIFORM_SCALING:性能与安全的取舍
宏 UNITY_ASSUME_UNIFORM_SCALING 是做什么的?从名字看,它是让 Unity 假设所有缩放都是均匀的。一旦定义了它,法线变换就直接退化成UnityObjectToWorldDir(normal),也就是直接用 unity_ObjectToWorld 的 3x3 部分变换法线,最后归一化。
为什么可以这样?因为等比缩放下,M = sR,逆转置 G = (1/s)R,两者只差一个常数因子,归一化后完全相同。所以为了性能,可以跳过逆转置这条路径。
这个宏默认是关闭的。如果你确保证项目里绝对没有非等比缩放,可以在 Shader 里手动开启它,省掉一次矩阵乘法的开销。但我个人建议不要贪这个性能,除非你是在做移动端极简 Shader 并且已经验证过所有模型都是等比缩放。因为只要美术在场景里把一个物体拉扁,这个宏带来的 bug 会立刻出现,而且极难排查——你看代码根本看不出问题,因为法线变换“看着就是对的”。
顺带一提,URP 里也有类似的处理,函数名和实现思路基本一致,核心逻辑都是“有非等比缩放时用逆转置,没有就退化为直接变换”。
4.3 手动实现时的三个常见错误
除了直接用 UnityObjectToWorldNormal,也有人喜欢自己写。我见过几个反复出现的错误写法:
第一种,不会取 3x3 部分:
o.worldNormal = normalize(mul((float4x4)unity_ObjectToWorld, float4(v.normal, 0))).xyz;这种写法把法线 w 设为 0,从数学上没问题,方向向量确实不受平移影响。但 float4x4 乘法的开销比 float3x3 大,而且阅读性差。既然只需要旋转缩放,建议显式转成 float3x3,让意图更清楚。
第二种,直接用对象到世界矩阵乘,完全不归一化后面就参与光照。非等比缩放下法线长度不为 1,光照计算里点乘会得到一个被缩放过的余弦值,导致表面过亮或过暗。很多人说“我已经用 UnityObjectToWorldNormal 了怎么还是暗”,其实问题出在函数内部有 normalize,而你如果手写时忘掉 normalize,效果完全不同。
第三种,在没有非等比缩放的项目里也用逆转置,性能上打折扣。性能差异确实很小,但如果你写过几千个 DrawCall 的移动端项目,你就会知道 Shader 里的每一个矩阵乘法都值得省。所以建议按 Unity 的做法:默认走逆转置,确认为等比缩放项目时再走直接变换。
4.4 负缩放与镜像:UnityObjectToWorldNormal 也没解决的坑
有一个坑是 UnityObjectToWorldNormal 没有帮你处理的,就是负缩放。当 Transform 的 scale 在某个轴上为负数,比如 scale(-1, 1, 1),或者奇数个轴为负数时,模型会发生镜像。此时矩阵 M 的行列式 det(M) < 0。
法线经过逆转置矩阵变换后,仍然垂直于切平面,但方向指向了几何内部。用这样的法线做光照,你会看到模型表面发黑、发暗,或者高光出现在背面。更麻烦的是,镜像还会导致三角形 winding 顺序反转,本来应该正面朝外的面变成了背面,背面剔除会把整个模型削掉一半。
常规处理方法是判断变换矩阵的行列式,如果是负数,就把法线再翻转一次:
float det = determinant((float3x3)unity_ObjectToWorld); float3 worldNormal = UnityObjectToWorldNormal(v.normal); worldNormal *= det < 0 ? -1.0 : 1.0;这种处理在一些对称建模工具生成的资产里特别重要。比如你做一个左右对称的角色,为了使左右共用一套 UV 和网格,可能会把右侧模型沿 X 轴镜像过去。如果不处理法线翻转,右半边的光照会整体出问题。这个问题在阴影上也会暴露:法线方向的偏转造成阴影偏移异常,容易出现闪烁。
如果做的是程序化生成网格,网格本身在脚本里被翻转,也建议在 C# 侧生成网格时就修正 winding 和法线,从源头避免负缩放,而不是留给 Shader 去判断。
5. 实操验证:写一个能“看”到法线方向的调试 Shader
5.1 可视化思路
光看代码不容易建立直觉。我建议你把法线方向直接可视化成颜色:把世界空间法线的 XYZ 分量从 [-1, 1] 映射到 [0, 1],然后输出到颜色。
因为 RGB 颜色分量可以表示 XYZ 三个方向,法线朝哪个方向,颜色就会偏哪个方向。比如朝上的面看起来偏绿,朝右的面偏红,朝前的面偏蓝。用这种 Shader 去调试非等比缩放的模型,法线有没有错,一眼就能看出来。
5.2 完整 Shader 代码
在 Unity 里新建一个 Unlit Shader,把下面的代码贴进去,挂到材质上:
Shader "Debug/NormalTransform" { SubShader { Tags { "RenderType"="Opaque" } Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include "UnityCG.cginc" struct appdata { float4 vertex : POSITION; float3 normal : NORMAL; }; struct v2f { float4 pos : SV_POSITION; float3 normalWrong : TEXCOORD0; // 错误变换 float3 normalCorrect : TEXCOORD1; // 逆转置变换 float3 normalUnity : TEXCOORD2; // 内置函数 }; v2f vert (appdata v) { v2f o; o.pos = UnityObjectToClipPos(v.vertex); // 错误:直接用对象到世界矩阵变换法线 o.normalWrong = normalize(mul((float3x3)unity_ObjectToWorld, v.normal)); // 正确:手动做逆转置变换 o.normalCorrect = normalize(mul(v.normal, (float3x3)unity_WorldToObject)); // 正确:Unity 内置 o.normalUnity = UnityObjectToWorldNormal(v.normal); return o; } fixed4 frag (v2f i) : SV_Target { // 切换下面三行,分别观察三种结果 float3 debugColor = i.normalWrong * 0.5 + 0.5; // float3 debugColor = i.normalCorrect * 0.5 + 0.5; // float3 debugColor = i.normalUnity * 0.5 + 0.5; return fixed4(debugColor, 1.0); } ENDCG } } }5.3 实测步骤与观察结果
在场景里放一个 Cube,把 Scale 设置成 (1, 2, 0.5),把材质赋上去,然后依次切换 frag 里三行代码。
用 normalWrong 时,你会看到六个面的颜色分布明显不对称。特别要注意左右两个面,因为 X 轴缩放是 1,而 Y 轴缩放是 2,Z 轴是 0.5,法线在这个变换下被明显掰偏。原本应该垂直于左面的法线,自动带上了 Y 分量,反映在颜色上就是这个面的绿色分量明显异常。
换成 normalCorrect 或 normalUnity 之后,六个面的颜色分布会回归直觉:左右面偏红,上下面偏绿,前后面偏蓝,而且颜色更“纯”更均匀。两个正确写法的结果应当完全一致。
如果你再做一步验证,可以给 Cube 增加一盏平行光,观察光照效果。用 normalWrong 时,高光位置和明暗过渡会随相机视角产生不自然的漂移;换成正确法线后,光照立刻“站住了”。
5.4 一个更容易看出差异的场景:压扁的球体
Cube 的对比已经够明显,但我觉得最能说明问题的是压扁的球体。新建一个 Sphere,Scale 设置成 (1, 0.2, 1)。这种形状下,球面的法线变化是连续的,错误的法线变换会让球面出现一个莫名其妙的“高光腰线”,或者明暗过渡完全不跟随表面曲率。
用刚才的调试 Shader 观察压扁球体,normalWrong 模式下,靠近赤道附近的法线颜色会异常偏蓝或偏绿,看起来像有涂色不均匀;normalCorrect 模式下,法线颜色分布才符合扁椭球的几何特征。我建议把这个实验做一遍,因为球体没有硬边,法线方向的连续性会把错误的“扭转”暴露得一清二楚。
6. 面试官追问的四个角度:怎么把这道题答得有层次
6.1 追问一:旋转和平移下,还需要逆转置吗
面试官问这个问题,是看你是不是真的理解“逆转置”的来历,还是仅仅背了结论。
旋转矩阵是正交矩阵,满足 R⁻¹ = Rᵀ,所以 (R⁻¹)ᵀ = R。也就是说对纯旋转,逆转置矩阵和原矩阵完全相同,直接用原矩阵就行。平移分量对方向向量没有影响,因为方向向量的 w = 0,平移列参与运算后结果为零。所以当变换只包含旋转和平移时,正常变换法线就是正确的,不需要任何额外操作。
6.2 追问二:等比缩放是不是也要逆转置
等比缩放 M = sR,逆转置 G = (1/s)R。G 和 M 之间只差一个常数因子 s²。归一化之后,方向完全一样。所以等比缩放下用原矩阵变换法线,只要最后 normalize,结果与逆转置矩阵一致。这也是 UNITY_ASSUME_UNIFORM_SCALING 宏存在的前提。
这里建议回答得严谨一点:数学上两者方向等价,但要注意归一化是必须的。等比缩放的倍数很大时,法线长度可能变成 0.001 或者 1000,直接拿去算光照,点乘结果会被放大或缩小,表面亮度完全错误。所以归一化不是可有可无,而是与变换方式配套的必要步骤。
6.3 追问三:负缩放和镜像怎么办
这是整道题里最有区分度的一个追问。负缩放意味着 M 的行列式小于零,手性发生翻转。逆转置矩阵仍然垂直于切平面,但是法线整体指向内部。面剔除也会因为 winding 顺序反转而失效。
正确做法是判断行列式符号,符号为负时翻转法线。Unity 的 UnityObjectToWorldNormal 没有处理这个情况,所以如果你做镜像模型,必须自己补充。回答这个问题时能主动提到背面剔除和 winding 的连带关系,会显得对渲染管线的理解更完整。
6.4 追问四:为什么 Unity 源码里要写成 mul(normal, unity_WorldToObject)
这个追问直接看你对 HLSL 矩阵运算的理解。我们想要的变换是 (M⁻¹)ᵀ · normal。如果直接求 M 的逆矩阵再转置,Shader 里要额外付出一次矩阵求逆的代价。Unity 利用引擎预计算的 unity_WorldToObject 矩阵,直接在 GPU 上完成变换。
关键在于 HLSL 的 mul 有两种书写方式。mul(matrix, vector)是把向量当列向量,做常规的矩阵乘列向量;mul(vector, matrix)是把向量当行向量,结果等价于 matrix 的转置再乘以该列向量。所以mul(normal, (float3x3)unity_WorldToObject)等价于(unity_WorldToObject)ᵀ · normal,正好就是逆转置矩阵作用在法线上。一个表达式同时处理了“求逆”和“转置”,连归一化都给你写好了。
6.5 延伸话题:法线贴图的 TBN 矩阵也踩同一块石头
如果面试还有第五轮,通常会跳到法线贴图。法线贴图里存的是切线空间的法线,要变换到世界空间需要 TBN 矩阵,也就是由切线 T、副切线 B、几何法线 N 组成的 3x3 矩阵。
关键点在于,TBN 里的几何法线 N 这一列,应该使用经过逆转置修正后的世界法线,而不是直接乘模型矩阵的“伪法线”。如果 TBN 里的 N 错了,法线贴图在非等比缩放模型上会出现明显的拉扯感,尤其是表面细节被压扁或拉长的部分。
有些实现甚至会直接对 T、B、N 三根轴都做逆转置变换,再重建正交基。这种做法更稳,但代价是 Shader 里多了几次矩阵运算。实操中,我会先确认模型有没有非等比缩放,有的话优先走 UnityObjectToWorldNormal 修正几何法线,再重建一组正交 TBN,别省这一步。
最后一件事:把经验写进规范,而不是留给下一任吃苦
这个知识点让我最深刻的体会,不是“考试答案记住了”,而是它总在你最想不到的地方出现。可能是美术压扁了一个石头模型,可能是程序化地形突然出现阴影闪烁,也可能是接手的旧项目里有一行看起来人畜无害的 mul(matrix, normal)。排查到最后,原因往往就是这一行。
所以我现在写 Shader 会有个习惯:凡是涉及法线变换,一律优先用 UnityObjectToWorldNormal,除非能明确证明这个项目用不到非等比缩放。同时,在代码审查里,任何人手写mul((float3x3)unity_ObjectToWorld, v.normal)我都会多问一句——你有没有确认过所有模型都是等比缩放?这个问题已经拦住过好几次潜在 bug。
如果你也是在团队里做渲染相关的工作,建议把这条经验沉淀到编码规范里:对象空间到世界空间的法线变换,默认走逆转置路径,禁止为了省一条指令直接乘模型矩阵。另外,编译 Debug Shader 可视化法线方向的时间成本极低,建议把它做成一个常用的调试工具,省得每次遇到光照异常都要靠猜。