1. 模型矩阵:局部坐标是怎么变成世界坐标的
1.1 模型矩阵的组成:T、R、S
做渲染的老哥都知道,一个物体能出现在屏幕上,靠的是四套矩阵一路接力:模型矩阵、视图矩阵、投影矩阵,最后才是视口变换。很多人天天写代码调shader,但模型矩阵到底在干嘛、它的逆矩阵有什么用,其实没仔细想过。这里我就把最基础也最容易被忽视的一环拆开聊透。
模型矩阵(Model Matrix)干的事情,简单说就是“把物体的局部坐标搬到世界坐标”。一个顶点在建模软件里定义时,坐标原点是物体自己的锚点,比如一个茶壶模型,壶底中心可能就是(0,0,0)。但是引擎里这个茶壶被放在了一个场景的某个位置,朝向某个方向,还被缩放成了某个大小。这中间发生的变化,就是模型矩阵。
模型矩阵通常由三个基本变换复合而成:
- 缩放矩阵S:用sx、sy、sz分别缩放三个轴。
- 旋转矩阵R:用绕x、y、z三个轴的角度组合,描述物体朝向。
- 平移矩阵T:用tx、ty、tz把物体挪到目标位置。
写成矩阵,就是M = T × R × S。这个顺序不是随便定的,而是经过了无数图形学前辈的验证。原因后面单独讲。
很多资料喜欢把旋转矩阵直接写成一个3×3矩阵,然后拼进4×4齐次矩阵的左上角;平移塞到第四列前三行;缩放作用在最里层。这是标准做法。我这么多年看下来,真正容易出问题的反而是顺序搞反,或者说“世界回归局部”时不知道要反过来用逆矩阵。
1.2 矩阵乘法顺序:先缩放、再旋转、后平移
这里有个核心概念必须说清楚:矩阵乘法不满足交换律。先旋转后平移,和先平移后旋转,结果完全是两回事。
想象一个立方体,先绕原点旋转45度,再沿x轴移动5个单位——移动方向是世界空间的x轴;如果先移动5个单位,再旋转45度,等于把已经偏移的立方体再旋转,旋转后它的位置不仅偏移了5个x,还偏移到了别的方向。这就是顺序的差别。
标准约定是先缩放、再旋转、最后平移。这样做的好处是:
- 缩放围绕物体自身原点进行,不会因为先平移导致缩放把物体“拽跑”;
- 旋转也是围绕物体自身原点旋转,旋转轴不会受到平移影响;
- 平移永远发生在这个已经缩放和旋转完毕的“局部框架”里,位置控制最直观。
所以在C++或引擎代码里组合矩阵时,常见的写法是:
mat4 model = translate(tx, ty, tz) * rotate(angle, axis) * scale(sx, sy, sz);如果你不小心把顺序写成scale * rotate * translate,物体就会整体天旋地转,而且位置完全不受控制。这是几乎所有图形学新手都会踩的第一个大坑。
1.3 为什么需要“逆过程”:世界坐标回局部坐标的业务需求
模型矩阵能把局部坐标变到世界坐标,那反过来呢?我经常遇到有人问:我有了世界空间里的一个点,怎么知道它在物体局部空间里的坐标?
这个需求不是凭空想出来的,实际项目中太常见了。举几个例子:
- 射线拾取:鼠标点击屏幕,发射一条射线到世界空间,射线和某个模型撞上了。碰撞点返回的是世界坐标,但如果你要判断碰撞点落在模型的哪个子区域,比如判断是否击中了车门还是车灯,就必须把世界坐标转换到模型的局部空间,和模型本身的几何数据做比较。
- 贴花与局部效果:在模型表面生成一个贴花,需要知道接触点相对于模型锚点的局部位置,才能正确映射贴花的uv或形状。
- 动画与IK:骨骼动画系统里,经常需要把世界空间的目标位置转换到某根骨骼的局部空间,才能计算关节转角。
- 模拟与物理:一些物理引擎返回的接触点默认在世界空间,做局部约束时也要转回局部。
这些场景统一指向一个数学工具:模型矩阵的逆矩阵。世界坐标乘以模型矩阵的逆,就能回到局部坐标。公式很简单:
局部坐标 = M⁻¹ × 世界坐标
但这背后有一堆值得深挖的东西。
2. 逆矩阵的几何意义与计算逻辑
2.1 逆矩阵到底在干什么:反向变换的三步
模型矩阵做的事情是把物体从局部搬到世界,那逆矩阵做的事情就是把世界坐标搬回局部。这正好对应了前面说的三步变换,只不过每一步都反向执行:
- 先把世界坐标平移回去:减去模型矩阵的平移量。
- 再反向旋转:把世界方向转回模型自身的朝向。
- 最后反向缩放:把世界坐标的尺度还原到局部尺度。
写成矩阵,就是:
M⁻¹ = S⁻¹ × R⁻¹ × T⁻¹
注意顺序反过来了。原本M = T × R × S,求逆后顺序完全颠倒,第一步做的是T⁻¹,最后一步才是S⁻¹。这个颠倒逻辑是理解“世界回归局部”的关键。
如果你不想死记硬背,可以这样推理:对任意一个点v,世界坐标 = T × R × S × v。想解出v,就得两边同时左乘逆矩阵。又因为(ABC)⁻¹ = C⁻¹B⁻¹A⁻¹,所以M⁻¹ = S⁻¹ × R⁻¹ × T⁻¹。这就是矩阵运算的基本法则。
实际代码里不用每次从头推导,直接用现成的矩阵求逆函数即可。但理解了这个展开式,你才能诊断出各种诡异问题。
2.2 正交矩阵与旋转矩阵的简化:转置代替求逆
如果模型矩阵只包含平移和旋转,没有缩放,那么这东西有个极好的性质:旋转矩阵是正交矩阵,正交矩阵的逆矩阵等于它的转置矩阵。
数学上的含义是:旋转矩阵的每一行、每一列都是单位向量且互相正交,把它转置一下,就正好是逆矩阵。这意味着你不需要跑一遍高斯消元或者伴随矩阵算法,直接转置就能拿到逆矩阵,性能和数值稳定性都更好。
实际的3D引擎和游戏里,很多模型不做非均匀缩放(或者完全没有缩放),那么求逆这件事就非常轻量:
// 三维旋转矩阵求逆 = 转置 mat3 rotation = mat3(model); mat3 inverseRotation = transpose(rotation);这里mat3(model)取的是模型矩阵的旋转线性部分,不包含平移列。对一个纯旋转加平移的变换,逆变换就是:
M⁻¹ = 先转置旋转矩阵,再对旋转后的位置减去平移量(注意顺序)。
很多引擎底层都会做这个优化,因为求逆计算比转置昂贵得多。如果你自己写图形学代码,遇到纯旋转+平移的模型矩阵,大胆用转置,别傻乎乎去跑通用的求逆函数。
2.3 法线变换的例外:为什么这里不能直接用模型逆矩阵
说到逆矩阵,法线变换是绕不开的特例。很多人以为法线也是向量,直接用模型矩阵带到世界空间就行了,结果光照一塌糊涂,模型某些面亮得发黑。
法线变换的正确做法是:用模型矩阵的逆转置矩阵,也就是( M⁻¹ )ᵀ。
这个逻辑折磨过无数人。原理其实不复杂:法线定义是表面切线的垂直方向,切向量经过模型矩阵(带缩放)变换后依然贴合表面,但你直接拿模型矩阵去变法线向量,当模型有非均匀缩放时,法线角度会被扭曲,不再垂直于表面。
举个例子:一个平面法线朝上(0,1,0),模型沿y轴放大2倍。这时候表面拉伸了,真正的法线方向没变,还应该是(0,1,0)。但如果直接用模型矩阵作用,得到(0,2,0),归一化后依然是(0,1,0),碰巧没问题。如果你沿x轴放大2倍、y轴不变,表面法线就会倾斜,直接乘模型矩阵就错了。
正确的思路是:法线在局部空间垂直于一切表面切向量,变换后必须依然垂直于变换后的切向量。这个约束最后推导出来的结论就是,法线必须乘以模型矩阵的逆转置。有兴趣的可以拿两个切向量和内积去推,十分钟能推完。
注意:如果模型矩阵只有旋转和平移,没有缩放,那么M的逆的转置恰好等于M本身,所以法线可以直接乘模型矩阵。这也是为什么大部分无缩放场景下没人发现这个坑。
3. 实战操作:把世界坐标打回局部坐标
3.1 基本流程:求逆、乘法、选项
把一个世界坐标点转回局部坐标,标准流程就三步:
- 拿到物体的模型矩阵M。
- 计算M⁻¹。
- 用M⁻¹乘世界坐标。
但这里有个非常容易忽略的细节:点向量和方向向量用的是同一个矩阵,还是不同的矩阵?
- 位置点(齐次坐标w=1):直接用M⁻¹乘完整4分量。
- 方向向量(齐次坐标w=0):不能用带平移的逆矩阵去乘。因为方向向量不应该被平移,一旦你把平移量混进去,方向就错了。
也就是说,把一个世界方向向量转回局部方向,正确操作是取M⁻¹的3×3线性部分(去掉平移),然后乘方向向量。这也正是法线变换里“逆转置”产生的一个分支场景。
很多图形学代码里封装了一个函数,专门做“变换方向”。我强烈建议你也这么干,避免每次手算矩阵然后忘了去掉第四列平移。
3.2 代码示例:GLSL内置inverse / 手动矩阵求逆
在shader里,GLSL直接提供了inverse()函数,能对一个mat4求逆。这个函数大部分显卡都支持,但要注意的是:统一在CPU端求好逆矩阵并传进去,比在shader里每顶点调用inverse()要划算得多。我见过有人图省事在顶点着色器里写inverse(modelMatrix),结果一个三角面几百个顶点每个都在算一遍逆矩阵,GPU气得发抖。
推荐的shader写法:
uniform mat4 modelMatrix; uniform mat4 inverseModelMatrix; // CPU侧算好传入 vec3 worldPos = ...; // 已知的世界空间坐标 // 世界空间点 -> 局部空间点 vec3 localPos = (inverseModelMatrix * vec4(worldPos, 1.0)).xyz; // 世界空间方向 -> 局部空间方向 vec3 localDir = mat3(inverseModelMatrix) * worldDir;如果你实在需要在CPU端手动实现矩阵求逆,常见做法是伴随矩阵法或高斯消元。4×4矩阵求逆有固定公式,但对一般人来说直接调数学库就好。常见的数学库(GLM、D3DX、UE4的FMatrix等)都有现成求逆,别自己重复造轮子,除非你在写一个没有依赖的引擎demo。
不过我还是强烈建议你自己实现一遍逆矩阵算法——不只是为了面试,而是为了理解矩阵运算的本质。写一遍之后,你会对“矩阵求逆为什么有数值稳定性问题”有切身体会。
3.3 性能与精度:该不该每帧求逆?
这是个很实在的问题。答案是:分场景。
如果一个模型的模型矩阵在一帧内没有变化,那就应该在CPU端每帧算一次逆矩阵,然后传给shader,而不是在每个顶点或每个像素里重复算。如果模型矩阵稳定不变,甚至可以只在初始化时算一次,缓存起来。
但如果物体每帧都在动,比如刚体模拟、角色动画里的骨骼矩阵,那逆矩阵也得跟着每帧更新。这也是一个常见的性能陷阱:有团队图省事,直接在shader里调用inverse(),因为平时模型数量少没感觉到问题;可一旦进入场景物体数量爆表,GPU开销立刻变大。
另一个被忽略的点是数值精度。通用矩阵求逆在矩阵接近奇异时,结果会变得非常不稳定。模型矩阵里的缩放极小或极大时,求逆结果会引入很大的数值误差。一个经验做法是:当你只是想把一个世界点转回局部点时,尽量用“先分解出平移、旋转、缩放,然后反向计算”的方式,而不是直接对着4×4矩阵求逆。因为平移和旋转在数值上非常干净,算出的逆矩阵误差小得多。
我自己的习惯是写一个InverseModelMatrix的辅助函数,内部先提取旋转部分(左上3×3),用它做转置,然后算出反向平移,最后组合成4×4。这个方案在绝大多数项目里够用,而且数值稳定。
3.4 射线拾取案例:把世界碰撞点打回局部空间
举一个完整的实战案例,帮你把这套流程串起来。
假设场景里有一个门模型,你要做鼠标点击拾取,点击后要知道玩家点的是门把手、门板还是门框。门模型有复杂的层级关系:门板上有门把手,门把手的模型矩阵等于“门的变换”乘“门把手相对门的变换”。
点击时:
- 从屏幕坐标生成射线,把射线转换到世界空间,做包围盒或三角形求交,得到世界空间下的碰撞点,比如(4, 1.5, 2)。
- 现在需要判断这个点在门把手的局部空间里落在哪个区域。拿到门把手的完整模型矩阵(注意是链式复合矩阵:父物体矩阵×子物体矩阵)。
- 求这个复合矩阵的逆矩阵,用逆矩阵乘世界碰撞点,得到门把手局部坐标。
- 拿局部坐标和门把手自身的包围盒、分片几何数据比对,判断命中区域。
这里有个特别容易翻车的地方:很多引擎的射线检测函数,默认返回的是“命中点”的世界坐标,这个坐标很精确;但模型的包围盒是在局部空间定义的。不转换直接比较,轻则误判,重则点永远点不中。我见过有同学把世界坐标直接和局部包围盒比大小,怎么调都差一点,最后发现是忘了逆变换。
另外还要注意:如果射线命中的是子物体(比如门把手),你需要的是门把手的模型矩阵,而不是门板的。这两个矩阵不一样,用错了会得到非常诡异的结果。
4. 常见问题速查表与避坑经验
4.1 我踩过的坑:向量和点的区别
第一个大坑就是前面反复提到的:把点当方向向量,或把方向向量当点。
一个世界方向向量,比如(0, 1, 0),你想转回局部方向,如果用完整的4×4逆矩阵乘,向量会被平移量带偏。尤其是模型在世界空间有较大平移量时,误差会被拉得特别大。
我自己有一次给某图像处理Demo写天空盒反射,把反射向量误用完整模型逆矩阵乘了一次,结果反射方向全乱了,天空盒的倒影以一种奇怪的方式扭曲。排查了半天才意识到:我乘完矩阵之后根本就没把w分量的平移动效去掉。
现在的习惯是:做方向变换时永远先用一个明确的函数,内部要么把第四行去掉,要么构造一个新的3×3矩阵来算。这比每次祈祷自己没忘要可靠得多。
4.2 问题速查表:逆矩阵与坐标变换常见病
这里整理一张速查表,基本覆盖了大多数实际问题。遇到诡异情况先照着排查一遍。
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 世界坐标转局部后,位置偏移巨大 | 用了转置矩阵代替逆矩阵,且模型含非均匀缩放 | 检查M是否为只含旋转和平移的正交部分;用完整求逆验证 |
| 法线方向错误,明暗不对 | 法线直接乘了模型矩阵,没乘逆转置 | 改为transpose(inverse(model))乘法线 |
| 方向向量包含莫名其妙的位移 | 把w=0向量乘了完整4×4矩阵 | 只乘3×3线性部分 |
| 点击射线命中但局部坐标计算不对 | 用了错误的模型矩阵(用了父矩阵而不是子矩阵) | 确认拿的是被命中物体的矩阵 |
| 逆矩阵计算结果NaN或无穷 | 模型矩阵里包含0缩放 | 检查物体是否有退化的缩放轴,避免为0缩放 |
| CPU端求逆每帧开销大 | 向量化或批量求逆没做好 | 缓存静态物体的逆矩阵,仅对动态物体每帧求逆 |
这里面最容易被忽视的是“用错了模型矩阵”这条。场景里经常有父子物体嵌套,你如果把世界坐标打回去的时候用了父物体的矩阵,或者用了某个构建半截的矩阵,结果自然错得离谱。所以每次做逆变换前,先花10秒确认你手里的矩阵是真的、完整的。
4.3 链式变换的逆:层级变换中的复合矩阵
现实项目里很少只有一个模型矩阵摆在那,更多是嵌套层级。比如角色模型有骨骼,骨骼树上有子骨骼;再比如载具模型里,门板是父节点,门把手是子节点。这时候要算某个子节点局部坐标的逆变换,得拿到了子节点完整的模型矩阵。
递推公式是:
子节点完整模型矩阵 = 父节点模型矩阵 × 子节点相对父节点的变换矩阵
求逆就变成:
子节点逆矩阵 = 子节点相对父节点的逆 × 父节点的逆
这个顺序非常容易写错。和前面四则运算一样,乘积的逆等于逆的反序乘积。我见过一个项目,为了求子物体局部坐标,先求了父逆再乘子逆,结果顶点全都跑到了奇怪的位置。
另外,如果你在引擎里同时持有物体组件、父子关系表,请务必使用引擎提供的接口拿“完整世界矩阵”或“全身矩阵”,不要自己手动从父到子逐级乘。手动乘的时候矩阵顺序一错,后面全错,而且查起来特别费时间。
4.4 实战经验:什么时候别用通用求逆函数
通用矩阵求逆函数不是万能的。在图形学里,模型矩阵的结构其实很特殊,它有平移、旋转、缩放,但没有透视裁剪那种病态结构。利用这一点,我们可以写出更快、更数值稳定的专用方法。
我的经验法则是:
- 如果只有旋转和平移:用转置+反向平移,最快最稳。
- 如果还带均匀缩放:先归一化旋转部分,再求旋转转置,最后缩放取倒数,依然很快。
- 如果有非均匀缩放:稍微复杂一点,可以提取出左上3×3,对其求逆(比如用伴随矩阵法或高斯消元),平移部分单独处理。
- 最保险的做法还是调用数学库的通用求逆,然后在关键路径上缓存。
最后分享一个我自己一直在用的小技巧:在你的代码库里写一个专门用于“变换逆矩阵”的辅助类,它内部维护“模型矩阵”和“逆模型矩阵”,所有变换都同时更新两边,这样整个项目里就不会有人去重复计算模型逆矩阵,也从根本上避免了方向向量误乘带平移矩阵的低级错误。
这套变换逻辑,入门看起来不过是一个矩阵求逆,实际用起来却能牵扯出向量分量、层级结构、数值稳定、性能开销这么多细节。下次再看到有人问“世界坐标怎么变回局部坐标”,你可以把这篇文章甩给他,然后告诉他:慢点来,先把自己手里的矩阵搞清楚。