在Unity里待久了你会发现写transform.position大概是新手期最兴奋、后期最心虚的一行代码。我早期做项目时第一步就靠它把物体放到关卡点位后来做物理交互和移动系统才发现直接改坐标背后有一整套为什么这样写的门道。这篇内容不聊高大上的架构只聊实践中躲不开的那些事坐标的本质、五种修改方式的取舍、踩进去过的坑以及一个能落地的小功能。如果你正在写角色移动、物体拖拽、UI过渡或者只是想让某个物体去某地而摸不准该用哪个API这篇应该对你有用。1. 先搞清楚transform.position到底在改什么1.1 position与localPosition世界坐标和父级坐标的区别Transform是一个组件每个挂它的物体都在场景里有自己的位置姿态。position是世界空间下的绝对位置也就是场景原点为基准的三维坐标localPosition则相对父物体而言以父物体的位置为原点。两者绝大部分时间不一样。我带过一个新人项目他想让一个跟随角色的小球平滑偏移到角色右上方写的是transform.position target.position new Vector3(1f, 2f, 0f);如果target刚好是一棵子物体树上的节点而小球又挂在同一棵树下结果会明显偏出一大截因为两个物体各自受到父物体变换的叠加。后来改成target.localPosition offset才正常。提示凡是在层级面板里把某个物体拖到另一个物体下就应该优先考虑用localPosition来表达相对关系否则很容易出现逻辑对了但画面偏了的诡异情况。1.2 一个对象本质上有几套位置一个物体在场景中的位置并不是单一数字Unity里至少包含这么几层transform.position世界坐标最直接也是Inspector里显示的Position没有父物体时显示的就是它。transform.localPosition相对父物体的坐标。transform.localEulerAngles、transform.rotation旋转相关旋转会影响位置计算的直观感受。如果挂了RectTransform则完全不一样它有自己的anchoredPosition和localPosition体系。另外即使你没主动写坐标物理引擎也会在固定更新里修改刚体的位置。所以要分清楚你改的是Transform数据还是通过物理系统改位置。这个区别会在后面的坑里放大。1.3 坐标更改的本质直接改变变换矩阵Unity内部管理这棵变换树每帧都会根据localPosition、localRotation、localScale算出世界变换矩阵。transform.position new Vector3(...)本质上是把世界矩阵的目标值反向换算到局部信息再更新整棵子树。这意味着改一次position可能连带着所有子物体一起更新一批矩阵运算。如果物体下有几百个子节点每帧大规模修改父节点坐标主线程的开销会实打实上涨。这也是为什么大批量单位移动时很多开发者会用Job System或直接culling而不是纯靠transform改。明白了这个原理你就不会奇怪为什么同一条移动代码在不同场景帧率不一样。2. 坐标改动的四种主流方式怎么选2.1 直接赋值transform.position / localPosition适用场景传送、复位、逻辑上把物体放到某处不需要逐帧过程。这种方式是硬性的瞬间生效。优点是没有中间态逻辑简单清晰。缺点是如果连续两帧直接把坐标改到不同位置玩家看到的就是瞬移跳变。如果每一帧都持续性这样改比如transform.position speed * Time.deltaTime * dir会有更微妙的物理问题后面细说。用直接赋值做移动时我建议始终带着deltaTime否则不同帧率的设备跑出来的速度完全不一样。新手最常见的bug就是把Update里的忘了乘Time.deltaTime。2.2 Transform.Translate相对位移的封装transform.Translate(Vector3.forward * speed * Time.deltaTime, Space.Self);Translate的本质是帮你把相对位移换算成世界增量再改position。它有两个坐标系选项Space.World和Space.Self。用Space.World时效果基本等于手动position 用Space.Self时则是朝物体自己的forward方向移动。我用Translate时最想强调的一点是它默认相对自己。如果你的物体时刻朝目标旋转那TranslateSelf是合理的而如果你只是想让物体沿世界坐标轴平移忘了传Space.World就会越走越怪。2.3 通过Rigidbody修改位置让物理引擎接管rb.MovePosition(targetPosition);这是和物理系统互动的正解。你改的不是Transform而是告诉刚体请把质心移到某个位置物理引擎会在下个物理步长内完成更新期间会同步处理碰撞检测和速度。如果你强行对带Rigidbody的物体直接改transform.position最常见的问题就是把物体塞进墙里、卡在地板里、触发奇怪的碰撞反弹。因为你的赋值绕过了物理检测物理引擎并不知道你是怎么进来的只能在下一帧尝试修正。这个坑我当年在做一个子弹弹射效果时踩得够深——子弹都穿过门了才发现是transform.position惹的祸。2.4 插值移动Lerp / MoveTowards / 协程严格来说这是赋值的一种特殊形式但非常适合做平滑过渡。transform.position Vector3.MoveTowards(transform.position, targetPosition, speed * Time.deltaTime);MoveTowards自带不越过目标点的特性对UI过渡、敌人巡航十分友好。Vector3.Lerp更适合做百分比插值经常配合Time.time做动画。实际项目中我压箱底的是这个组合current Vector3.Lerp(current, target, 1 - Mathf.Exp(-k * Time.deltaTime))能获得和帧率无关的平滑跟随效果而且不会因为每帧缩短百分比导致尾部拖尾感。3. 那些年踩过的坐标坑3.1 直接改坐标导致碰撞穿透某家公司有个物理方块推到墙前的功能。当时的实现是transform.position new Vector3(transform.position.x speed * Time.deltaTime, 0, 0);效果方块直接插进墙里然后下一帧被乱弹出来甚至偶尔穿墙而过。原因是这行代码绕过了物理系统相当于每一帧人为把物体推进碰撞体内部物理引擎只会在碰撞回调里做修正频率跟不上就穿透了。正确思路如果物体有Rigidbody且你需要推它优先用AddForce或rb.MovePosition如果物体是纯视觉没有碰撞需求直接改坐标没问题。判断标准就是它要不要参与物理碰撞要就别绕过引擎。3.2 在协程里修改坐标的不流畅问题常见写法是在协程里每帧给position赋值目标距离很远时没问题但目标偏小时会看到明显的抖动。用IEnumerator时如果你写了while (Vector3.Distance(...) 0.01f)而速度步长是固定的可能在下一帧会过冲导致在目标点附近来回摇摆。处理方式是运动结束时对position做一次瞪眼校正直接设为目标值或者用更平滑的速度曲线。我在项目中会在循环末尾加一行transform.position Vector3.MoveTowards(transform.position, target, step); if (Vector3.Distance(transform.position, target) 0.02f) { transform.position target; break; }这样永远不会有停在半空的感觉。3.3 UI坐标和世界坐标混用开发UI界面时改坐标的手感完全不同。RectTransform和Transform是两套坐标体系。新手在Canvas下找物体的transform.position会发现数值巨大或根本不对因为Canvas通常有缩放。此时要看具体需求。如果只是让UI元素在画布里移动用rectTransform.anchoredPosition单位是屏幕逻辑像素如果要把世界坐标点映射到UI坐标用Camera.WorldToScreenPoint再把结果交给UIBillboard那套思路。我自己做过一个准星定位敌人血条的小功能核心代码就是Vector3 screenPos mainCamera.WorldToScreenPoint(enemy.transform.position); dragableUI.anchoredPosition screenPos - uiCanvasRoot.anchoredPosition;这里一定要判断屏幕坐标是否在相机前方否则z值为负时血条会跑到屏幕右下角。3.4 出现NaN后项目当场瘫痪坐标修改中出现NaN的最大来源是向量值包含了0/0或无穷大。最常见的是把normalized用在零向量上Vector3 dir target.position - transform.position; transform.position dir.normalized * speed * Time.deltaTime; // 如果target自身dir为0dir.normalized是(NaN, NaN, NaN)一旦某个对象的位置变成NaN它所在层级的所有子物体都会跟着失效而且很难通过Inspector恢复。排查时可以写一段扫描代码检查float.IsNaN(transform.position.x)。我更狠的预防是写了一个基类移动组件每次移动前都检查方向向量的长度小于某个极小值就直接退出。3.5 与动画系统的坐标争夺如果物体上有Animator并且动画Clip里包含Position相关的关键帧那么动画播放会每帧覆盖你的脚本赋值。表现出来就是脚本疯狂改坐标但物体依然按动画节奏走偶尔还会抽搐。解决方式有两条要么把动画Clip里的变换曲线删掉让动画只改旋转要么在应用坐标前调用[Animator]的applyRootMotion false或者用OnAnimatorMove接管位置输出。我通常倾向于删动画里的position曲线因为保留更容易产生迷惑行为。4. 手把手写一个平滑移动到指定点的小功能4.1 需求分析和设计取舍假设需求很简单让一个物体在若干秒内从当前位置匀速移动到目标位置期间支持被打断。看起来简单其实藏了三个问题如何避免瞬移如何在被打断时平滑停止如何让目标点动态变化方案选择上我先排除协程因为协程在处理动态目标时比较绕除非用递归改写再排除Tween插件比如DOTween因为这篇我先把原生写法讲透。最终用Update MoveTowards实现代码短、依赖少。4.2 实现代码public class SmoothMover : MonoBehaviour { [SerializeField] private float speed 5f; [SerializeField] private float stopDistance 0.02f; private Vector3 _destination; private bool _isMoving; public void SetDestination(Vector3 target) { _destination target; _isMoving true; } public void StopMove() { _isMoving false; } private void Update() { if (!_isMoving) return; Vector3 current transform.position; Vector3 step Vector3.MoveTowards(current, _destination, speed * Time.deltaTime); transform.position step; if (Vector3.Distance(step, _destination) stopDistance) { transform.position _destination; _isMoving false; } } }这段代码配合任何对象都能用。有一点要注意stopDistance不能太夸张否则物体远未到点就被截断但也不能小于0.001f否则浮点误差可能导致永远停在0.0009f的距离上。0.02f是我在60帧、速度5以内时常用的值。如果你要在移动过程中不断更新目标直接反复调用SetDestination即可因为每次只改目标值不会产生地址歧义。4.3 加入朝向让物体边移动边转向很多情况下光有位置还不够物体移动时应朝向运动方向。一个最直接的写法Vector3 dir step - current; if (dir.sqrMagnitude 0.001f) { transform.rotation Quaternion.LookRotation(dir); }上面用sqrMagnitude而不是Magnitude是为了性能长度平方开根号能省则省。这句之后这个功能就可以直接用于巡逻小兵朝下个点走过去。4.4 如果坚持用协程可以怎么写有些需求里单位要在固定时间内完成移动而不是匀速到点。这时候协程配合Vector3.Lerp更直观public IEnumerator MoveInFixedTime(Vector3 target, float duration) { Vector3 start transform.position; float elapsed 0f; while (elapsed duration) { elapsed Time.deltaTime; transform.position Vector3.Lerp(start, target, Mathf.SmoothStep(0f, 1f, elapsed / duration)); yield return null; } transform.position target; }关键点是把start在协程开始时保存下来而不是在循环里每次重新取transform.position否则由于lerp的A点一直在变化会出现永达不到B点的问题。这个技巧我讲过不止一次因为很多人会惯性写成position Lerp(position, target, ...)结果物体越来越慢像在追一个永远缩小的余数。5. 坐标实操里的一些进阶心法5.1 移动前先判断要不要动很多人写运动脚本时习惯每帧无条件执行移动代码。其实如果目标点已经被踩到了后边的运算全部是浪费。可以先判断if ((_destination - transform.position).sqrMagnitude _stopDistanceSqr) { // 执行移动 }这个判断的意义不只是性能更多是避免边界处抖动。stopDistanceSqr是预先算好的stopDistance * stopDistance能减掉每帧开方运算。5.2 调试坐标请用射线和Gizmos你很难通过Inspector实时看出位置变化的规律尤其是在游戏运行期间。我一般会在脚本里加两行调试private void OnDrawGizmosSelected() { Gizmos.color Color.yellow; Gizmos.DrawWireSphere(_destination, 0.05f); }这样即使在播放模式暂停你也能选中那个物体看到目标点的黄色线框标记。如果是复杂寻路再用Debug.DrawLine把轨迹画出来比猜坐标靠谱得多。5.3 关于大批量单位移动的性能建议如果你的游戏里同时有几百个带有简单移动逻辑的对象每帧都改transform.position即便没有物理碰撞也会让CPU主线程出现明显峰值。此时有两个进阶方案可以做抽屉底牌把移动逻辑放在LateUpdate里不对这里容易受动画影响不如放在FixedUpdate中统一物理步调但视觉会稍显僵硬。更值得做的用Transform的hasChanged和UnityEngine.Jobs或者数据驱动的坐标更新把所有动态物体的目标位置先算好再用TransformAccessArray批量写入。这个方案写起来需要点Data-Oriented功底但性能提升明显。如果你用的是URP还能配合遮挡剔除但那是后话。5.4 坐标修改和保存回放做回放系统时很多人的第一反应是记下每帧所有物体的position。这样会有很大的内存开销而且想跳帧播放很麻烦。我建议只记录操作变更点。例如玩家在t1.2秒时发出了移动指令t2.3秒时到达某个点。回放时对这些关键帧插入移动状态中间用同样的移动算法插值补全这样回放表现就与实况一致。前提是你的移动算法是确定性的不要依赖Time.time这种全局时间而是依赖fromTime到toTime的相对区间。5.5 坐标更新与多摄像机如果你的场景里有多个Camera并且你的对象需要在屏幕空间做交互一定注意要指定具体摄像机而不是直接Camera.main。尤其在坐标转换和射线检测叠加的时候多个相机可能产生一对多映射。我的习惯是写一个全局缓存类把所有主用摄像机提前引用好脚本里直接拿缓存既避免Find也避免选错。6. 最后分享一个压箱底的移动平滑技巧结尾聊点真正提升手感的经验。我最近做一个小作品时发现把MoveTowards改成指数平滑后手感会有质的提升。普通写法是固定速度物体启动和停止都很生硬。指数平滑写法是transform.position Vector3.Lerp(transform.position, _destination, 1f - Mathf.Exp(-_lerpSpeed * Time.deltaTime));_lerpSpeed越大物体越快根上目标这个写法天然模拟了快速接近-靠边减速-平稳停下的曲线而且不受帧率影响。用来做相机跟随、抓取物品飞向背包都非常自然。实测_lerpSpeed在10时跟随感很跟手轻微滞后但不算飘在20时接近硬绑定。大家可以根据手感调整。如果你已经写厌了每帧手动判断距离不妨换这一行代码试试。至少在坐标更改这件事上它是少数能让玩家一眼感觉舒服的细节。