1. 这需求为什么值得用矩阵:先聊聊“跟随”的本质
做 Unity 开发久了,你会发现“物体跟随”这四个字能延伸出一大堆玩法:镜头跟着角色、炮塔跟着目标、挂件跟着武器、NPC 跟着巡逻路径点。最直觉的做法无非两种:把跟随者直接拖成目标的子物体,或者每帧把目标的 position 和 rotation 赋给跟随者。这两种方案在简单场景下都好使,但一旦碰到非等比缩放、离线烘焙、跨组件解耦这类需求,它们就会开始漏风。
我最早意识这个问题,是在做某项目里一个机械臂演示时。机械臂的末端要挂一个喷枪模型,但如果直接把喷枪做成子物体,机械臂某一节如果是非等比缩放的,喷枪会被拉长变形,甚至连带阴影和碰撞体一起出错。当时我临时用了一个思路:把喷枪相对于机械臂末端的“相对位姿”存成矩阵,每帧重新计算世界矩阵再写回 Transform。这次实测让我彻底理解了矩阵在跟随这件事上不可替代的价值——它其实是把“跟随”翻译成了纯粹的坐标空间换算,不再依赖场景层级,也不受父子缩放污染。
所以这篇文章要聊的东西,不是教你“怎么赋值一个 Transform”,而是把物体跟随这个需求拔高一层:用 Matrix4x4 记录相对变换,用矩阵乘法在目标与跟随者之间做坐标空间桥接。这个过程既可以用 Unity 的 TransformPoint / InverseTransformPoint 简化,也可以手动通过 TRS 矩阵完整控制,能适应平滑插值、离线烘焙、相机跟随等进阶玩法。适合两类读者:一类是刚把 Transform 和坐标系理清楚的 Unity 新人,想弄明白矩阵到底有什么用;另一类是写过一些跟随逻辑但总在非等比缩放或旋转顺序上翻车的开发者,想找一套更稳的通用方案。
背后有很多值得掰开揉碎讲的细节:矩阵乘法的顺序为什么不能反、worldToLocalMatrix 到底帮我们做了什么、为什么直接赋值 localToWorldMatrix 会报错、非等比缩放为什么会引发剪切变形。这些内容初看像数学题,但在实际项目里几乎每一条都对应的是一次线上 bug。下面我按从原理到实现、再到实战排查的顺序,把整个思路完整串一遍。
2. 前置知识:TRS 矩阵与 Unity 的坐标变换接口
2.1 一个矩阵打包平移、旋转、缩放
先回忆一下图形学基础:一个 4x4 的变换矩阵,可以同时表达平移、旋转和缩放。Unity 里的 Matrix4x4 是列主序的,平移量存在第 4 列的前三个分量里,前三列分别代表局部坐标系的 X、Y、Z 轴在世界坐标下的方向向量,每列的模长对应相应轴上的缩放系数。TRS 三个字母分别代表 Translation、Rotation、Scale,Unity 提供了 Matrix4x4.TRS 这个静态方法,把一个位置向量、一个四元数和一个缩放向量组合成一个完整的变换矩阵。
一个物体挂到场景里,它的 Transform 最终会生成一个 localToWorldMatrix,表示“局部坐标→世界坐标”的变换。如果这个物体没有父物体,这个矩阵就等于 TRS(本地位置, 本地旋转, 本地缩放);如果有父物体,还需要左乘父物体的 localToWorldMatrix,再加上自身的局部变换,一层层叠上去。
这个过程用一句话概括:localToWorldMatrix = 父级链上所有局部矩阵的连乘结果。而它的逆矩阵 worldToLocalMatrix 做的事情正好相反,把世界坐标换算回这个物体的局部坐标。在 Unity 里,你不需要手工求逆,直接用 Transform.worldToLocalMatrix 就能拿到。
2.2 Unity 的 worldToLocalMatrix 与 localToWorldMatrix
这两个接口是矩阵跟随的核心工具。大多数人不常用它们,因为平时操作 Transform.position / rotation 就够了。但它们背后的矩阵换算,才是理解跟随逻辑的关键。
举个例子:已知一个目标物体 Target,想求一个世界坐标点 P 在 Target 的局部空间里长什么样。代码很简单:
Vector3 localPos = target.worldToLocalMatrix.MultiplyPoint(P);如果你把 target 的 localToWorldMatrix 理解为“把局部坐标吐到世界”的机器,那么 worldToLocalMatrix 就是它逆向的机器。这两个矩阵在跟随逻辑里的角色非常清晰:
- 记录相对关系时:用
target.worldToLocalMatrix * follower.localToWorldMatrix,把 follower 的世界矩阵搬进 target 的局部空间。 - 还原相对关系时:用
target.localToWorldMatrix * offsetMatrix,把 offset 矩阵从 target 局部空间搬回世界空间。
这两条看似简单,但决定了整个方案能不能成立。如果你在写这种矩阵乘法时突然觉得“为什么方向反了”,多半是混淆了左右乘的顺序。Unity 里 matrix 乘向量的规则是matrix * vector,复合矩阵则是A * B表示先应用 B 再应用 A,这在 3.2 节里我会用一个翻车案例详细复盘。
注意:Project 里常见的报错“Transform.localToWorldMatrix is read-only”并不是因为你调用了 Getter,而是因为你尝试对返回值直接赋值。Unity 不允许你绕过 Transform 组件的属性设置,单独给矩阵赋值。正确做法是把矩阵分解,重新赋给 position / rotation / localScale,或者生成一个新的 Transform。
3. 核心实现:用矩阵同步目标的位姿
这一节进入正题。我会从一个最朴素的需求出发:让 A 物体始终跟随 B 物体,保持启动瞬间的相对位置和相对旋转,不改变相对缩放。用矩阵实现的思路分三步:记录偏移矩阵、每帧还原矩阵、把矩阵写回 Transform。
3.1 记录相对偏移矩阵:Start 阶段的快照
第一步是记录“A 相对于 B 的偏移”。这里说的偏移不是一个 Vector3,而是一个 4x4 矩阵,它包含了相对位置和相对旋转的完整信息。数学表达为:
offset = B.worldToLocalMatrix * A.localToWorldMatrix这样得到的 offset 矩阵,表示“B 的局部空间里 A 的变换”。你甚至可以把它想象成一张快照:如果此刻 B 停止不动,而 A 的位置和旋转恰好由 offset 描述,那么 A 在世界中的表现和它当前的表现完全一致。
对应的代码很简单:
public class MatrixFollow : MonoBehaviour { public Transform target; private Matrix4x4 offset; void Start() { offset = target.worldToLocalMatrix * transform.localToWorldMatrix; } }有两点值得注意。第一,transform.localToWorldMatrix拿到的是跟随者当前在世界中的完整矩阵,而不是它相对某个父物体的局部矩阵。这意味着即使这个跟随者原本有父物体,也不影响偏移矩阵的准确性。第二,必须在 Start 或初始化时记录,不要在 Update 里每帧都重新记录,否则就永远跟不上了。
3.2 每帧还原:矩阵乘法与属性回填
每帧要做的事情,就是把 offset 矩阵从“目标局部空间”翻译回“世界空间”:
A.worldMatrix = B.localToWorldMatrix * offset这里的顺序是从右往左读:offset 先把 A 放到 B 的局部空间,然后 B 的 localToWorldMatrix 把整个坐标空间搬回世界。如果你把顺序写反,得到的结果就是“把 B 的世界矩阵塞进 A 的局部空间再变换”,坐标会飘到完全错误的位置。
有了世界矩阵之后,不能直接写transform.localToWorldMatrix = worldMatrix,因为这是一个只读属性。Unity 提供了Matrix4x4.decompose或者手动提取列向量来拆解。手动拆解更直观:
void LateUpdate() { Matrix4x4 world = target.localToWorldMatrix * offset; transform.position = world.GetColumn(3); transform.rotation = world.rotation; transform.localScale = world.lossyScale; }这里我用了 LateUpdate 而不是 Update,原因很简单:如果你同时控制了目标物体的移动,那么目标在 Update 里先动,跟随者在 LateUpdate 里跟随,可以避免一帧内的跟随时序问题。相机跟随尤其应该放在 LateUpdate。
这段代码已经可以实现完整的刚体式跟随:位置、旋转都能同步,缩放不会继承。因为 offset 矩阵是在 Start 时固定的,后续不管目标怎么移动、旋转,A 相对于 B 的关系都不会被破坏。
3.3 手动构建 TRS 矩阵:不依赖 Unity 内置接口
如果你不甘心只是用封装好的 API,完全可以手动构建一遍这个过程。用Matrix4x4.TRS重建目标的 localToWorldMatrix,再把 offset 乘上去:
Matrix4x4 targetMatrix = Matrix4x4.TRS(target.position, target.rotation, target.lossyScale); Matrix4x4 worldMatrix = targetMatrix * offset;这样做有个好处:你可以自由控制哪些变换要被记录。假如你只想跟随位置,不跟随旋转,那就在记录 offset 时把旋转部分拆出来单独存。下面是一个位置跟随版本的完整代码:
public class PositionOnlyMatrixFollow : MonoBehaviour { public Transform target; private Vector3 localPosOffset; void Start() { localPosOffset = target.InverseTransformPoint(transform.position); } void LateUpdate() { transform.position = target.TransformPoint(localPosOffset); } }这其实是矩阵方案的一种降维形式。InverseTransformPoint和TransformPoint的底层走的就是 worldToLocalMatrix / localToWorldMatrix 的乘法,只是 Unity 把这个过程封装成了更友好的 API。当你只想同步位置时,没必要生成完整的 4x4 矩阵。
但反过来,如果目标可能会旋转,而你只记录了一个位置偏移,不给旋转做记录,就会出现“物体跟着目标走但朝向不动”的效果,这对某些 UI 挂件或飘字效果反而是想要的。所以选择完整矩阵还是位置向量,取决于你到底要“跟住”目标身上的什么属性。
3.4 平滑跟随:插值的是矩阵还是变换值
很多项目希望跟随带一点阻尼感,让跟随物稍微慢半拍,比如摄像机、附身灵体、拖尾特效。这时就不能直接每帧把矩阵算出来赋值了,需要对结果做插值。
最简单的做法是不对矩阵做插值,而是对 position 和 rotation 分别插值:
Vector3 targetPos = target.localToWorldMatrix * offset * Vector3.zero?这里注意,取出目标位置更稳妥的方式是直接用目标矩阵的列向量:
Vector3 desiredPos = (target.localToWorldMatrix * offset).GetColumn(3); Quaternion desiredRot = (target.localToWorldMatrix * offset).rotation; transform.position = Vector3.Lerp(transform.position, desiredPos, 0.1f); transform.rotation = Quaternion.Slerp(transform.rotation, desiredRot, 0.1f);为什么不直接对矩阵做插值?因为矩阵的每个分量并不均匀地代表位置、旋转和缩放,直接对 float 做 Lerp,旋转部分会产生不自然的中间状态,尤其在转角接近 180 度时,矩阵分量会突然跳变。Quaternion.Slerp 和 Vector3.Lerp 处理旋转与位移插值更符合直观预期。
如果你想做带速度上限的匀速跟随,我建议不要用 Lerp 的 alpha 系数,而是用时间增量计算最大位移与最大角速度,再手动限制这一步的增量。否则不同的帧率会导致跟随快慢不稳定。
4. 进阶实战:无父子关系的相机跟随与离线烘焙
单纯写一个跟随脚本不难,难的是把它放进真实业务里。这一节我挑三个有代表性的场景,分别说明矩阵方案对比传统父子级方案的优势,以及它们各自要注意什么。
4.1 相机跟随:相对位移不继承缩放
相机跟随是矩阵方案最典型的应用。传统做法是直接把相机挂在角色子节点上,但这样做问题很多:如果角色有非等比缩放,相机会被拉扯变形;如果角色在过场动画中切换父节点,相机的稳定会被破坏。用矩阵记录相对偏移,相机就不再受角色缩放的污染,因为它只认目标的世界矩阵,不认识目标的父子结构。
实现思路与 3.2 节完全一致。唯一额外的考虑是布局:相机通常不仅需要一个位置偏移,还希望在看向目标时能保持一定的旋转。这时候 offset 矩阵里的旋转就是“相机相对目标朝向”的偏移。比如,你想让相机始终在目标身后 5 米、上方 2 米,看向目标。你可以先摆好一个空物体作为相机锚点,把这个锚点放到你想要的位置,然后相机的 offset 记录锚点相对目标的变换。运行后,每帧把锚点的世界矩阵取出来,让相机追踪锚点,再用 LookAt 看向目标。
实际操作中,我还会额外做一层旋转阻尼。如果目标高速旋转,相机直接跟随会非常晕。这时只插值相机位置,旋转用 LookAt 的缓动版本,或者用四元数 Slerp 限制相机的转向速度。
4.2 离线路径烘焙:把跟随逻辑写成矩阵快照
另一个很惊艳的应用是离线烘焙。比如一段过场动画,某个车门要跟着车厢在一段时间内保持相对位姿,但又不希望把车门做成车厢的子物体,因为后面车门可能要单独弹开。这时你就可以在动画产出的每个关键帧采样target.worldToLocalMatrix * follower.localToWorldMatrix,把整个 offset 随时间变化存成一个数组。回放时按时间区间插值 offset 矩阵,再乘以当前的车厢矩阵,生成车门的世界矩阵。
这段代码的结构大体长这样:
[System.Serializable] public class MatrixKeyframe { public float time; public Matrix4x4 offset; } public class MatrixPlayback : MonoBehaviour { public Transform target; public List<MatrixKeyframe> keyframes; public void SampleAndApply(float time) { Matrix4x4 offset = SampleOffset(time); Matrix4x4 world = target.localToWorldMatrix * offset; transform.position = world.GetColumn(3); transform.rotation = world.rotation; transform.localScale = world.lossyScale; } private Matrix4x4 SampleOffset(float time) { // 实现分段线性插值,对矩阵中的16个分量做Lerp即可 } }这里有一个细节:矩阵插值不能用常规的 4x4 float 线性插值处理旋转,因为旋转矩阵的分量不满足线性关系。但对于关键帧比较密的离线数据,直接分量插值通常肉眼看不出来问题;如果关键帧间隔过大出现扭曲,就需要把 offset 矩阵拆成 position 和 rotation,对 rotation 用四元数 Slerp,再把两部分重新组合成矩阵。
这种烘焙方案的好处在于:回放代码完全不需要知道“车门原本怎么跟着车厢动”,它只需要当前时间和当前车厢矩阵,就能精准还原出当时的一切。把它换成任何场景道具、披风挂件、武器后坐力表现都一样成立。
4.3 非等比缩放下的陷阱与被裁剪的阴影
非等比缩放是矩阵方案能解但父子关系非常吃力的经典场景。所谓非等比缩放,就是一个物体的 localScale 三轴分量不相等,比如 (2, 1, 1)。如果你把一个子物体挂在这种父物体下面,子物体的旋转会跟父物体的缩放矩阵产生复合效果,形成剪切变形,视觉上就是物体被“斜着拉长”了。
为什么会这样?因为变换矩阵的复合是乘法,缩放矩阵先作用,然后旋转矩阵再作用,这会引入剪切分量。标准三维变换要求旋转矩阵是正交方阵,但缩放矩阵破坏了正交性,结果就是子物体的法线不再正确,阴影、光照都会出问题。而矩阵方案中,偏移矩阵不受目标缩放影响,它只描述相对位姿,最后乘出来的世界矩阵写回 Transform 时,被 Unity 分解成 position、rotation、lossyScale,Unity 的分解逻辑能够容忍非等比缩放。
需要额外提醒的是:即使使用矩阵方案,也不要拿到一个 lossyScale 全是零或负数的目标。负缩放会导致旋转分解异常,某些平台会出现物体镜像翻转。如果你发现目标物体在场景里被故意缩放出负值,建议把它包一层空父节点,用正缩放做实际变换。
5. 常见问题排查与避坑清单
矩阵方案踩过的坑比想象中要多。我按排查频率整理了一张表,每一条都对应我实际项目中处理过的案例。
| 现象 | 根因 | 解决方案 |
|---|---|---|
| 跟随物瞬移到世界原点 | 初始化时 offset 矩阵记录时机不对,Transform 还没准备好 | 改在 Start 或首次执行时记录,不要在 Awake 里读场景中其他物体的矩阵 |
| 跟随物绕目标反方向旋转 | 矩阵乘法顺序写反 | 记住 A * B 表示先应用 B 再应用 A,记录用 inverse,还原用 forward |
| 局部缩放被丢或被重置 | 从矩阵取旋转时用了错误列向量 | 手动拆解要按 GetColumn(0/1/2) 分别取 X/Y/Z 轴,用 lossyScale 传回 |
| 旋转角度在大角度时抖动 | 直接对矩阵分量做线性插值 | 改用 Quaternion.Slerp 或四元数插值,不要对旋转矩阵做 Lerp |
| 跟随物比目标提前动了一帧 | Update 执行顺序混乱 | 跟随逻辑放到 LateUpdate,确保目标物体先完成运动 |
| 关掉目标后跟随物残留 | 目标销毁时没有清理 | 在 OnDisable / OnDestroy 中做空判断或订阅事件 |
5.1 跟随物瞬移到世界原点再跳回目标位置
这个问题最常发生在把代码从 Update 移到 LateUpdate,或者从 Awake 改成 Start 的过程中。原因很简单:如果你在 Awake 里记录 offset,此时目标物体可能还没完成自身的初始变换(比如被动画系统或者另一个脚本赋值),你记录下来的 offset 是错的。等目标进入正确位置后,跟随者每帧都会用错误的偏移去计算目标位置。
排查思路是加日志,分别打印 Start 和第一帧 Update 时目标的位置和 offset。如果 offset 的第四列与预期差几万,那基本就是初始化时机问题。
5.2 旋转方向反了:矩阵乘法顺序的自我检查
矩阵乘法不满足交换律,A * B 和 B * A 通常结果完全不同。我调试时常用一个土办法验证顺序正确性:把目标物体在场景里做一组简单旋转,比如绕 Y 轴转 45 度,然后观察跟随者是不是也转了同样的 45 度。如果转的是 -45 度,说明矩阵乘法顺序反了。
更严格的验证方式是:记录 offset 后先不要移动目标,手动给目标赋一个绕 Y 轴旋转 90 度的四元数,然后检查跟随者是否相对正确偏移并转了 90 度。这个测试只需要一分钟,但能省下半天猜谜。
5.3 欧拉角抖动、万向锁与四元数插值
在矩阵分解时,如果用world.rotation.eulerAngles去做插值,你会在某些角度看到跳动值,比如 359 度突然跳回 0 度。这是欧拉角表示本身的缺陷。矩阵方案的正确旋转插值姿势是先把取到的四元数保存下来,用 Quaternion.Slerp 插值,最后再赋给 transform.rotation。不管你从矩阵里取 Euler 角还是四元数,赋回去时都用四元数,不要在中间环节转成欧拉角再处理。
如果实在需要控制旋转速度而不是用 Lerp 比例,可以用Quaternion.RotateTowards,它支持指定最大角度增量,比手写 Slerp 更符合匀速旋转的直觉。
5.4 性能与 GC:矩阵运算每帧开销实测
矩阵本身是值类型(struct),每帧做几次矩阵乘法不会产生 GC 分配。我实测过在 1000 个跟随物体上跑这套逻辑,每帧多出的大概只有几次矩阵乘法的 CPU 耗时,Unity 的 Profile 上看几乎可以忽略不计。真正的性能瓶颈在别处:
- 如果你在每帧都调用
Matrix4x4.TRS并构造中间矩阵,虽然不会 GC,但会产生较多临时值,CPU 缓存会受影响。 - 如果你为了调试每帧打印矩阵到 Console,GC 和 IO 成本会瞬间飙升。
- 大量跟随物同时启用时,LateUpdate 里的 Transform 赋值会造成内部脏标记传递,可能影响渲染批处理。
优化建议:对不经常变化的跟随目标,可以每 5 帧或 10 帧采样一次矩阵,中间帧用上一次的结果;对需要平滑的,改成在插值端做,而不是每帧都完整计算矩阵。
6. 个人实操经验与一点补充
写到这里,我特别想分享两个在实际项目里帮过大忙的小技巧,它们不属于教科书内容,但比很多理论知识更能救命。
6.1 先画坐标系再写代码
矩阵跟随第一次上手时,最容易绕晕的就是“这个矩阵到底是把坐标从哪送到哪”。我的习惯是拿一张纸,画出两个方块,一个代表目标,一个代表跟随物,然后标出世界坐标系和两个局部坐标系。不管写代码还是调试,先确认这个坐标变换链条对不对。等思路通了,再打开 IDE 写乘法。
这个习惯帮我至少避免过三四次方向颠倒的 bug。矩阵从设计上就没有“直观”一说,画出坐标系以后,你将能看到究竟是谁在乘谁,以及为什么记录时要用 worldToLocalMatrix、还原时要用 localToWorldMatrix。
6.2 用调试绘制验证矩阵结果
第二个技巧是使用 Debug.DrawLine 或 Gizmos.DrawRay,把矩阵在运行时画出来。比如你手动构建了一个目标矩阵,就可以这样验证:
void OnDrawGizmos() { if (target == null) return; Matrix4x4 m = Matrix4x4.TRS(target.position, target.rotation, target.lossyScale); Gizmos.color = Color.red; Gizmos.DrawRay(m.GetColumn(3), m.GetColumn(0) * 2f); Gizmos.color = Color.green; Gizmos.DrawRay(m.GetColumn(3), m.GetColumn(1) * 2f); Gizmos.color = Color.blue; Gizmos.DrawRay(m.GetColumn(3), m.GetColumn(2) * 2f); }这段可视化代码能让你立刻看出矩阵的 X/Y/Z 轴朝向是否合理,也能帮助排查缩放对矩阵的影响。如果画出来的轴不是标准直角,或者长度明显不对,说明矩阵里混入了剪切分量,多半是乘了一个非正交矩阵。
最后再分享一个更实用的小技巧:在跟随逻辑里,如果你想临时“冻结”跟随,不要直接去改矩阵乘法,而是加一个开关,在关闭时把上一次计算出的世界矩阵缓存住,持续回填。这样重新开启时不会出现突变,体验上非常平滑。矩阵方案的好处正在于此——你手里始终保留着一份“完整状态”,想暂停、回放、转向,都只需要在矩阵层面操作,而不需要关心目标对象的运动逻辑。
物体跟随听起来是个小功能,但当你把它抽象为坐标空间的矩阵变换后,它就能从父子关系中挣脱出来,适用于更复杂的场景设计。希望这篇基于实战经验的拆解,能让你在下一次写跟随逻辑时,多一种更稳健的选择。