TRS 的顺序为什么重要:Matrix4x4.TRS 在项目中的用法

Unity 已经帮我们把大多数坐标变换都封装在 Transform 里了,但只要开始写工具、程序化生成、特殊动画或者自定义网格,Matrix4x4.TRS 很快就会出现。

我自己在项目里最常碰到它的地方,其实不是“我要秀矩阵”,而是普通 Transform 节点一多,点位生成和调试就开始变得很脏的时候。

这时候最容易犯的错是:

知道有 T、R、S,却不知道顺序为什么不能随便换。

这篇就专门讲这个问题。

一、TRS 不是三个标签,而是一条变换流水线

T 是 Translation,R 是 Rotation,S 是 Scale。

它们都可以写成矩阵,然后连乘成一个总矩阵。

关键在于:

矩阵乘法有顺序。

先缩放再旋转,和先旋转再缩放,结果不一样。

所以 TRS 不能被理解成“把三个属性装在一起”,而是“这三个动作按某个顺序串起来”。

二、为什么一般会先 Scale,再 Rotate,再 Translate

最常见的直觉是:

  1. 先在物体自己的局部空间里做缩放
  2. 再把这个局部空间旋转到目标方向
  3. 最后整体搬到世界某个位置

这个顺序最符合大多数内容生产和运行时逻辑。

因为缩放通常希望沿局部轴发生,旋转通常希望围绕局部原点发生,平移最后只是把整个结果放到目标位置。

如果你把平移放前面,很多操作就会变成“绕世界原点打转”。

三、一个特别典型的错误:本来想原地旋转,结果绕远点转圈

这类 bug 常见于:

  • 自己拼矩阵
  • 自定义 gizmo
  • 程序化摆放物件
  • 特效跟随系统

症状通常是:

物体看起来不是原地变换,而是绕某个奇怪的点公转。

本质原因就是:

你把平移和旋转的乘法顺序写反了。

四、Matrix4x4.TRS 帮你封装了常规顺序,但你要知道它在帮你做什么

Unity 提供:

1
Matrix4x4 matrix = Matrix4x4.TRS(pos, rot, scale);

它很方便,但不能只停留在“会调”。

你要知道这个矩阵最终表达的是:

一个局部空间中的点,先经过缩放、再经过旋转、最后平移到目标位置。

这样后续你再用:

1
Vector3 worldPos = matrix.MultiplyPoint(localPos);

就知道自己在做什么,而不是把它当黑盒。

五、项目里什么时候会手动用 Matrix4x4.TRS

我自己觉得至少有这几类场景很常见:

1. 程序化摆放物件

比如一条道路、一个棋盘、一个阵列、一个地块拼接系统,需要根据规则批量生成位置和方向。

如果你先在局部空间定义模板点位,再乘一个 TRS,总体会比每次手写 position/rotation 偏移更稳定。

2. 自定义编辑器工具

比如场景里批量预览刷点、绘制本地包围盒、编辑器下做布局辅助,这时你常常有“局部模板 + 世界实例化”的需求。

3. 顶点或网格处理

某些运行时网格变形、顶点缓存、批量合并逻辑里,直接乘矩阵比绕很多 Transform 节点要直接得多。

4. 特效和挂点偏移

如果一个技能轨迹、命中特效、提示框位置不是简单地跟某个节点完全重合,而是相对于局部轴有一段可配置偏移,用 TRS 表达会更清楚。

六、MultiplyPointMultiplyVector 在这里再强调一次

用 TRS 矩阵做变换时,最常见的两种数据是:

  • 一个局部位置点
  • 一个局部方向向量

它们不能混用。

1
2
Vector3 p = matrix.MultiplyPoint(localPoint);
Vector3 v = matrix.MultiplyVector(localDir);

前者会吃进平移,后者不会。

如果你把方向拿去做点变换,经常会得到一个“方向看起来对,数值却带偏移”的诡异结果。

七、非等比缩放下,TRS 会把问题放大

如果 scale = (2, 1, 0.5) 这种非等比缩放参与进来,很多直觉会开始失灵。

尤其是当你后续还把这个结果继续喂给别的系统,比如:

  • 法线计算
  • 物理近似
  • 子节点朝向
  • 自定义包围盒

这时就要非常谨慎。

因为 TRS 不是错了,而是它忠实地保留了非等比缩放对坐标系的影响。

八、一个很实用的写法:先在局部定义,再统一乘矩阵

例如你要在角色前方摆 5 个预警点,可以这样写:

1
2
3
4
5
6
7
var trs = Matrix4x4.TRS(transform.position, transform.rotation, Vector3.one);

for (int i = 0; i < 5; i++)
{
Vector3 local = new Vector3(0f, 0f, 1.5f * i);
Vector3 world = trs.MultiplyPoint(local);
}

这个思路的优点是:

逻辑先在局部空间表达,很干净;真正落到场景时,再统一映射到世界。

对于技能、阵型、道路、棋盘格,这类写法都很稳。

如果换成二合或棋盘类项目,通常可以进一步写成“本地模板 + 根节点 TRS”的形式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Vector3[] localSlots =
{
new Vector3(-1f, 0f, -1f),
new Vector3( 0f, 0f, -1f),
new Vector3( 1f, 0f, -1f),
new Vector3(-1f, 0f, 0f),
new Vector3( 0f, 0f, 0f),
new Vector3( 1f, 0f, 0f)
};

Matrix4x4 boardMatrix = Matrix4x4.TRS(boardRoot.position, boardRoot.rotation, boardRoot.lossyScale);

foreach (Vector3 localSlot in localSlots)
{
Vector3 worldSlot = boardMatrix.MultiplyPoint3x4(localSlot);
Debug.DrawLine(worldSlot, worldSlot + Vector3.up, Color.green, 5f);
}

这种写法的价值,不只是能算出点位,而是后面整个棋盘搬家、旋转甚至切换朝向时,槽位规则都不用重写。

九、什么时候不必强行手写矩阵

也别反过来走极端。

如果只是普通挂点、普通跟随、普通父子关系,直接用 Transform API 更清晰。

矩阵适合的是:

  • 你需要显式控制变换链
  • 你要批量处理大量点位
  • 你不想创建太多临时节点
  • 你在写底层工具或渲染相关逻辑

否则一上来就全改成矩阵代码,只会让维护成本变高。

十、总结

Matrix4x4.TRS 最重要的价值,不只是“把位移旋转缩放拼起来”,而是让你可以显式、稳定地控制一条空间变换流水线。

只要你真正理解顺序问题,很多“为什么位置差一点”“为什么绕错点旋转”的 bug 都会好查很多。

下一篇我会把话题切到 Shader,讲顶点从模型空间一路走到裁剪空间时,MVP 矩阵链到底在干什么。