非等比缩放为什么会搞坏法线:normal matrix 与渲染细节

很多人学矩阵时,前面都还挺顺。

但我自己观察下来,真正把很多人拦住的不是 MVP,而是法线。因为前面的空间变换大多还像“位置搬家”,到法线这里你第一次会碰到“看起来像方向,处理方式却不能照抄”的情况。

旋转、缩放、平移都能理解,MVP 也能接受。

但一到法线,就开始出现一种很典型的疑惑:

顶点位置明明用 ObjectToWorld 变过去就行,为什么法线不能也这样直接乘?

答案通常在非等比缩放这里被彻底暴露出来。

一、法线不是普通方向向量

从形式上看,法线像一个方向向量。

但它承担的语义不是“朝哪里走”,而是“这个面垂直于哪个方向”。

也就是说,法线最重要的特征不是长度,而是它和切平面的垂直关系。

这决定了法线在变换时,不能简单地照抄顶点位置的处理方式。

二、为什么纯旋转时问题不明显

如果一个物体只做旋转,法线直接跟着旋转通常没问题。

因为旋转会保持角度关系,垂直关系不会被破坏。

等比缩放时,问题也不算突出,因为各轴缩放比例一致,整体几何关系相对稳定。

但一旦出现非等比缩放,比如:

1
scale = (2, 1, 0.5)

事情就变了。

三、非等比缩放为什么会破坏法线直觉

假设一个表面原本是斜着的。

你把模型在 x、y、z 三个轴上按不同倍数拉伸后,表面切线方向变了,但如果你还把法线只当普通向量同样去乘,它就不一定还和新表面保持垂直。

这就会直接影响光照结果:

  • 高光位置不对
  • 明暗过渡怪异
  • Lambert 或 PBR 结果不稳定

所以问题的根本不是“数值不漂亮”,而是物理含义被破坏了。

四、normal matrix 的本质

法线要保持和表面切平面的垂直关系,正确的处理方式通常不是直接乘 Model 矩阵,而是乘它左上 3x3 部分的逆转置。

也就是常说的 normal matrix。

写法上常见表达是:

$$
N = (M^{-1})^T
$$

更准确地说,是针对线性部分来处理,而不是把整个位移也拿进去。

五、为什么是逆转置

这里如果硬推导,文章会变得太学术。

工程上你可以先抓住一句话:

它是在修正非等比缩放带来的“垂直关系失真”。

顶点位置关心的是点怎么落位。

法线关心的是面法向关系怎么保持正确。

这两件事不是一回事,所以对应的矩阵处理也不一样。

六、Unity 里什么时候你会明显撞上这个问题

至少有几类场景特别容易中招:

1. 角色或道具被父节点非等比缩放

模型位置没问题,但光照开始怪。

2. Shader 里自己写世界空间法线

如果你偷懒直接拿对象空间法线乘 unity_ObjectToWorld,在非等比缩放下很容易错。

3. 自定义网格或程序化模型

如果法线是你自己算、自己传,后续变换链稍微处理错一点,画面就会有明显缝隙或阴影错误。

七、Unity Shader 里通常怎么避免自己踩这个坑

很多内置宏或常见写法已经帮你做了处理。

比如一些世界空间法线转换宏,本质上就是在替你做正确的矩阵变换。

所以如果只是常规表面着色,尽量复用成熟宏比自己乱写稳定得多。

但前提是你得知道:

这些宏不是魔法,它们是在替你处理法线专用的变换规则。

如果你是自己写顶点到世界空间法线,最少要保证思路像下面这样,而不是直接拿位置那套乘法照抄:

1
2
float3 worldNormal = normalize(mul((float3x3)unity_WorldToObject, v.normal));
worldNormal = normalize(worldNormal);

或者直接走 Unity 已有的辅助函数:

1
float3 worldNormal = UnityObjectToWorldNormal(v.normal);

后者在工程里通常更稳,因为它已经把很多容易忘的细节封装掉了。

八、一个很实用的排查方法

如果你怀疑光照异常跟法线有关,可以先做这几步:

  1. 暂时把父节点非等比缩放改成 (1,1,1)
  2. 观察异常是否立刻消失。
  3. 在 Shader 里把法线可视化输出成颜色。
  4. 对比对象空间法线、世界空间法线是否连续合理。

如果一改缩放就恢复正常,十有八九就是法线变换链有问题。

九、客户端工程里一个常见误区

很多人会把“法线问题”归类成纯美术或纯 Shader 问题。

其实不完全是。

因为运行时层级、挂点、骨骼、程序化缩放策略,都是客户端逻辑在定。

如果业务代码里大量引入非等比缩放,却没有意识到它会影响渲染表现,那最后排查成本会非常高。

十、一个更务实的团队建议

如果不是特别必要:

  • 逻辑层尽量少用非等比缩放
  • 美术资源层尽量在 DCC 工具里把比例处理好
  • Shader 层优先使用成熟法线转换宏

这样会比等到线上出现光照怪异再追矩阵链省事得多。

十一、总结

法线之所以难,不是因为概念多,而是它逼着你意识到:

“看起来都是方向向量”的东西,在不同语义下,矩阵处理方式并不相同。

理解了这一点,你后面写光照、自定义材质、程序化模型时会稳很多。

下一篇我会把话题拉回客户端更高频的地方:UI、屏幕、世界坐标之间的来回换算。