ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Unity矩阵跟随:Matrix4x4空间变换链原理与三种实现

Unity矩阵跟随:Matrix4x4空间变换链原理与三种实现

Unity里让一个物体跟随另一个物体,大多数人第一反应是“直接改Transform.position就行”。日常原型这么写确实没问题,但一旦到了复杂项目——队伍阵型、动态加载的场景、需要保持相对偏移的挂点、或者要在多套坐标空间之间来回切换——直接赋值会暴露出各种隐藏问题。矩阵方案不是炫技,而是用Matrix4x4把位置、旋转、缩放统一封装成一条完整的“空间变换链”,复制矩阵就等于复制了目标的世界姿态,这也是引擎底层实际工作的方式。

这篇文章会把矩阵跟随从原理讲到代码,再讲到我实测中踩过的坑。适合正在做RPG队形、载具挂点、武器装备点、以及所有“物体跟着另一个动但不完全一样动”场景的Unity开发者;刚入门矩阵的朋友也能通过跟随这个例子把矩阵从“听说过”变成真正会用。

1. 为什么用矩阵,而不是直接改Transform

1.1 直接赋值方案的三个现实问题

最直觉的跟随写法谁都会写:

void Update() { transform.position = target.position; transform.rotation = target.rotation; }

这个小Demo能跑,但仔细想有三个问题。

第一,它丢失了“相对偏移”。如果你希望物体跟随的是目标前方一米、偏左三十度的一个挂点位置,直接赋值做不到,还需要自己算偏移,而偏移一旦涉及旋转就麻烦。第二,它把“世界坐标”和“本地坐标”混在一起,一旦目标位于某个嵌套的子物体层级中,target.position是它的世界坐标,但你的物体可能处于另一个父物体下,直接赋值会导致父节点叠加的变换全部失效。第三,它完全没有空间隔离的思路,想复用这段逻辑到不同场景,几乎只能复制粘贴。

如果在Update里直接使用Transform层级——把跟随物设为目标的子物体——也能解决部分问题,但子物体方案在运行时动态改父级容易引发坐标跳变,而且会破坏原有继承缩放,尤其在UIBatch合并、动态加载场景里会出现连锁问题。矩阵方案正是为了规避这些麻烦。

1.2 矩阵方案的核心思路在哪里

Unity里的Matrix4x4本质上就是“一个坐标系相对于另一个坐标系的完整描述”。它把旋转、平移、缩放全部压缩在4x4的数字网格里,并且可以用矩阵乘法来叠加任意数量的坐标变换。

跟随的核心思路很简单:每帧提取目标的世界矩阵(localToWorldMatrix),把它乘上你自己的相对偏移矩阵,得到你自身应当处的位置姿态,然后把这个矩阵拆回Transform字段。因为矩阵同时携带了旋转和位移,所以自然就能保持相对偏移、继承旋转、同步缩放。

从另一个角度看,矩阵方案相当于把“跟随”从“每帧读几个数字”变成了“每帧做一次空间状态的整体复制”。这不仅更接近Unity内部渲染的真实情况,更在逻辑上统一了“目标世界姿态 -> 相对偏移 -> 自身世界姿态”的链路,方便后续把同一套逻辑复用到任意物体上。

2. 吃透Matrix4x4再动手,代码只是最后一步

2.1 矩阵里那16个数字到底代表什么

Matrix4x4有4行4列共16个数字,但你在实际使用时不需要记住全部。最核心的是区分“位移部分”和“旋转缩放的3x3部分”。

从localToWorldMatrix来看,最后一列(m03, m13, m23)就是这个物体在世界空间中的位置。左上角的3x3子矩阵则保存了物体的局部坐标轴在世界空间中的朝向,以及缩放的倍率。这就是为什么你可以直接从一个矩阵里同时取出position和rotation。

有一点需要注意:引擎内部矩阵运算遵循固定的行向量或列向量约定。Unity的Matrix4x4在文档中强调“列向量优先”,也就是说变换结果是矩阵左乘向量:result = M * vector4。这决定了后续所有矩阵乘法都严格区分顺序,写反了得到的坐标空间完全不是你想的那个。

2.2 从矩阵到Transform的“三角分解”

拿到一个世界矩阵后,真正做跟随还需要把它分解回position、rotation、scale三个部分,因为Transform组件只认这三种字段。

Unity没有内置一个万能的分解函数,但提供了一个很接近的工具:Matrix4x4.Decompose。它接收一个矩阵,输出scale、rotation、position三要素。不过这个方法有坑——如果矩阵里含有非等比缩放(x缩放2倍、y缩放1倍、z缩放3倍),解出来的rotation依然可能是错的,原因在于旋转矩阵和缩放矩阵混合在一起后,单一四元数无法同时表达“旋转后的非等比缩放”。这是我在第5节问题排查中会展开的重点,这里先埋个引子。

日常如果不涉及非等比缩放,直接用Decompose完全够用。如果涉及,我更推荐直接绕开矩阵的旋转分解,只对目标做四元数的常规运算,避免掉进数学陷阱。

3. 三种矩阵跟随的写法,覆盖绝大多数需求

3.1 方案一:直接复制目标世界矩阵

最简单的场景是:物体需要和目标长得一模一样,位置、朝向、缩放全部同步。比如一个“镜像物体”,或者一个需要完全贴合布娃娃角色的碰撞体,再或者一个由程序生成的、想要跟随主相机视角的辅助相机。

using UnityEngine; public class SimpleMatrixFollower : MonoBehaviour { [SerializeField] private Transform target; private void LateUpdate() { if (target == null) return; // target.localToWorldMatrix 是目标“本地空间 -> 世界空间”的完整矩阵 Matrix4x4 worldMatrix = target.localToWorldMatrix; transform.position = worldMatrix.GetPosition(); // 注意:这里直接取 rotation 在处理非等比缩放时有隐患,稍后会讲 transform.rotation = worldMatrix.rotation; transform.localScale = worldMatrix.lossyScale; } }

这段代码一看就懂,但有两个细节需要说明。

用LateUpdate而不是Update,是为了尽量“延迟到渲染前再采样”,避免目标在Update中移动后,跟随物计算的位置先于目标被渲染,造成一帧的视觉错位和抖动。这个属于跟随时非常典型的时序问题。

GetPosition是Unity提供的便捷方法,它从Matrix4x4里提取位置列,比自己写m03、m13、m23更安全,也更容易维护。

3.2 方案二:保持相对偏移的矩阵跟随

这是矩阵方案最有价值的场景——物体跟随目标,但要始终保持一个相对位置和相对旋转。最典型的就是载具上的座位挂点、角色背上的武器挂点、队伍里围绕队长呈圆形分布的队员。

思路是先缓存在“跟随开始的那一帧”,自身世界矩阵相对于目标世界矩阵的偏移;之后每帧都用这个偏移乘上目标的当前世界矩阵,得到自身的新世界矩阵。

using UnityEngine; public class RelativeMatrixFollower : MonoBehaviour { [SerializeField] private Transform target; [SerializeField] private Vector3 positionOffset = new Vector3(0f, 1f, -2f); [SerializeField] private Vector3 rotationOffset = new Vector3(0f, 0f, 0f); private Matrix4x4 relativeMatrix; private void Start() { // 构造“初始相对矩阵”:这个矩阵表示“自身在目标本地坐标系里的位置姿态” Matrix4x4 trs = Matrix4x4.TRS(positionOffset, Quaternion.Euler(rotationOffset), Vector3.one); // 与目标世界矩阵相乘后,得到的就是自身世界矩阵 relativeMatrix = trs; } private void LateUpdate() { if (target == null) return; // 自身世界矩阵 = 相对偏移矩阵 * 目标当前世界矩阵 // 注意乘法顺序不能反,反了就变成“目标跟随自己” Matrix4x4 selfWorldMatrix = relativeMatrix * target.localToWorldMatrix; // 分解矩阵,写回 Transform Matrix4x4.Decompose(selfWorldMatrix, out Vector3 scale, out Quaternion rotation, out Vector3 position); transform.SetPositionAndRotation(position, rotation); transform.localScale = scale; } }

这里矩阵乘法顺序是本方案的核心。为什么是relativeMatrix * target.localToWorldMatrix?简单理解:先把目标世界矩阵作为基础空间,再在这个空间上叠加一个“相对目标本地坐标系的偏移”。因为Unity是列向量优先,乘法从右往左生效,target.localToWorldMatrix先生效,relativeMatrix后生效。

你可以把relativeMatrix理解成“我相对于目标那个坐标系要偏移多少”。这个偏移是基于目标本地坐标系的——目标一转动,你的位置也跟着转动,就像挂在角色背上的武器一样。这一点是普通Transform赋值做不到的:如果用position + target.position的写法,你只能在世界空间里算偏移,目标转头时武器不会跟着转。

如果需要动态修改相对偏移,不用重新计算整个矩阵,只需要用Matrix4x4.TRS根据新的PositionOffset和RotationOffset重新构建relativeMatrix就行。这也暴露了矩阵的一个好处——偏移逻辑被统一在了一个数据结构里,不用同时维护好几个Vector3。

3.3 方案三:把矩阵分解写成通用工具

实际项目里跟随逻辑经常会同时出现在好几个类里,与其每个类都写一遍分解代码,不如封装一个工具方法。

using UnityEngine; public static class MatrixFollowUtility { /// <summary> /// 将 Matrix4x4 安全分解并写回 Transform /// 注意:非等比缩放场景下 rotation 会不准确,包含非等比缩放时请改用其他方案 /// </summary> public static void ApplyMatrixToTransform(Transform targetTransform, Matrix4x4 worldMatrix) { Matrix4x4.Decompose(worldMatrix, out Vector3 scale, out Quaternion rotation, out Vector3 position); targetTransform.SetPositionAndRotation(position, rotation); targetTransform.localScale = scale; } /// <summary> /// 计算一个物体相对于另一个物体的“世界相对矩阵” /// selfWorldMatrix 是自身的世界矩阵,anchorWorldMatrix 是锚点/目标的世界矩阵 /// </summary> public static Matrix4x4 CalculateRelativeMatrix(Matrix4x4 selfWorldMatrix, Matrix4x4 anchorWorldMatrix) { return selfWorldMatrix * anchorWorldMatrix.inverse; } }

封装后的使用场景是这样的:某型号的运载工具上要挂不同尺寸的箱子,每个箱子都只需要在Start里调用一次CalculateRelativeMatrix拿到相对矩阵,然后在LateUpdate里用方案二的乘法去做跟随。所有箱子共享同一套逻辑,新加挂点零成本。

有一个细节我特别想提醒:锚点矩阵的inverse计算在Start阶段做一次就好,不要每帧都在LateUpdate里做。虽说矩阵求逆的代价现在看起来不大,但在有几百个跟随物时,每帧一次逆运算会白白浪费大量CPU时间;更稳妥的做法是缓存相对矩阵,而不是每帧都重新求逆。

4. 矩阵跟随必须注意的时序与空间问题

4.1 LateUpdate与Update的选择直接决定画面是否抖动

跟随逻辑放在Update还是LateUpdate,在大多数项目里并不会立刻暴露问题。但当你把相机也放进场景,并且相机自身逻辑也写在Update里时,顺序就可能出错。

我自己的做法是:所有跟随物统一放在LateUpdate。因为Unity的执行顺序是先Update后LateUpdate,目标在Update里的移动会在LateUpdate之前完成,跟随物读取到的就是“目标这一帧最终的位置”,而不是“上一帧尾巴上的位置”。渲染发生在所有Update和LateUpdate之后,所以跟随物能拿到最新的目标状态,画面节奏更顺。

如果跟随的是相机目标,尤其需要注意这一条。相机如果也需要跟随某个物体,应当把相机的跟随逻辑放在所有普通跟随物之后执行,或者在相机脚本的LateUpdate里做。总之一句话:跟随逻辑永远不要在Update里和目标的移动逻辑抢时间。

4.2 矩阵手性和坐标约定的常见失误

Matrix4x4的乘法不是交换的:A * B和B * A结果通常完全不同。放在跟随场景里,如果乘法顺序反了,常见结果就是物体会莫名其妙地“围着目标做反方向转动”,或者位置一直对不上。排查时优先检查矩阵乘法顺序,其次检查手性。

Unity的坐标系是左手系,旋转矩阵的构造和引擎内部的矩阵值都有特定约定。如果你在外部生成矩阵(比如从某个数据结构里读到了欧拉角),直接用Matrix4x4.TRS重新构造,不要尝试手工拼矩阵,手工拼出的矩阵在手性上翻车的概率极高。

4.3 启用非等比缩放时,矩阵旋转分解会失真

这个坑值得单独写一节。如果目标物体在某个轴上有非等比缩放——比如一个门板,x方向拉长了3倍,y方向还是1——它的localToWorldMatrix里左上角3x3已经不是纯旋转矩阵,而是旋转和缩放混合在一起的结果。这时候调用matrix.rotation或者Decompose提取出的四元数,可能和你在Inspector里看到的rotation值不一致,旋转表现会怪怪的。

我的处理方案很简单:跟随场景里如果目标有非等比缩放,不要提取旋转,而是直接只同步位置,或者基于目标本身的rotation属性去做旋转运算,绕开矩阵分解。位置提取一般不受非等比缩放影响,因为位置就是最后一列的平移向量。

4.4 矩阵方案的性能真相

有人担心矩阵运算会比直接赋值慢很多。实测下来并不是这样。

Matrix4x4的四则运算在Unity里经过高度优化,对于一个跟随物来说,每帧两三次矩阵乘法和一次Decompose的开销可以忽略不计。真正的性能压力来自滥用:比如几百个物体同时在自己Update里对同一个目标求逆矩阵、反复调用Decompose去解只需要位置的矩阵。

优化的两个原则:一是能缓存的结果绝不在每帧重复计算,比如相对矩阵只算一次;二是能只取位置就直接GetPosition,不要把整个矩阵拆成Transform再赋值,拆的过程涉及更多数据搬运。遇到大量跟随物时,还可以考虑用JobSystem批量处理矩阵数据,但多数项目到这一步之前早就用别的方案优化了。

5. 常见问题与排查技巧实录

5.1 高频问题速查表

现象可能原因排查方向
跟随物飞到很远的位置或旋转乱转矩阵乘法顺序写反检查代码里relativeMatrix和target.localToWorldMatrix的先后顺序
跟随物抖动、闪一下Update/LateUpdate执行顺序不匹配目标在Update中移动,跟随逻辑移到LateUpdate
rotation无法匹配目标角度目标有非等比缩放放弃矩阵旋转分解,改用Transform的rotation属性做旋转同步
目标动了,跟随物纹丝不动target.localToWorldMatrix没有每帧刷新确认是否在Start里只缓存了一次矩阵,需要改成每帧获取
本身有父物体时,同步后位置偏一层直接把世界矩阵赋值给localPosition把从矩阵提取的结果写进SetPositionAndRotation,或者先转成相对父节点的本地矩阵

5.2 我踩过的一个典型坑

某次做队伍阵型Demo时,我给每个队员都挂了一个矩阵跟随脚本,目标都是队长。最开始一切正常,但队员只要初始位置不在原点,就会出现第一帧闪跳——队员从世界原点“弹”到目标旁边。排查了很久发现,Start里计算相对矩阵时,我下意识用了transform.localToWorldMatrix,但此时物体的Transform数据还没初始化完毕,matrix里读出来的位置是原点。

这个问题的核心是:在Start阶段,引擎会先处理一部分初始化,但如果你在Awake里修改了位置,Start里读到的矩阵可能还是修改前的。稳妥做法是在Start里不要依赖自己当前的矩阵状态,而是先手动写入一次想要的初始位置,再计算相对矩阵;或者推迟到第一次LateUpdate再初始化相对矩阵。

5.3 不想处理这个坑的替代方案

如果不是特别需要底层矩阵干预,Unity官方也有更便捷的API可以简化跟随问题:Transform.SetParent配合世界位置保持模式,或者简单的DOTween、插值方案。但如果你做的是需要自定义偏移矩阵、动态切换跟随坐标系、或者批量处理多个跟随物的复杂系统,矩阵方案依然是最稳、最可控的底层工具。

我个人的习惯是:初级跟随(完全一致)用方案一,挂点跟随(相对偏移)用方案二,启动阶段涉及动态空间换算时用方案三的封装。矩阵并不总是最快解法,但它是“当麻烦出现时,你能打开引擎看到底层逻辑”的那把钥匙。

这里额外分享一个我自己很受用的小技巧:跟随代码里所有涉及矩阵乘法的表达式,写完后立刻在注释里标明坐标空间,比如“相对矩阵 * 目标世界矩阵 = 自身世界矩阵”。项目过了一个月再回来看,这一行注释能帮你省下大量回忆和调试时间。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进