Unity游戏开发:基于localPosition的平滑跟随系统实现与优化

发布时间:2026/7/8 17:36:15
Unity游戏开发:基于localPosition的平滑跟随系统实现与优化 1. 项目概述从“跟随”这个基础需求说起在游戏开发里尤其是角色扮演、动作冒险或者策略类游戏中“跟随”是一个再基础不过但又至关重要的功能。想象一下你的主角身后跟着一个忠诚的宠物或者一支由AI控制的士兵小队需要尾随玩家前进甚至是一个摄像机需要平滑地追踪主角的运动——这些场景的核心都是一个物体我们称之为“跟随者”需要根据另一个物体“目标”的位置持续地调整自己的位置。实现跟随效果乍一看很简单不就是每帧把跟随者的位置设置成目标的位置吗但如果你真这么做了往往会得到非常僵硬、不自然甚至“鬼畜”的效果。物体可能会瞬间“闪现”到目标身边或者在目标急转弯时因为计算延迟而出现奇怪的抖动。更深入一点当跟随者和目标处于复杂的层级关系比如都在一个会移动的平台上时直接使用世界坐标进行运算很容易导致跟随逻辑混乱。这就是为什么我们今天要聚焦于localPosition。在Unity的Transform组件中position代表世界空间中的绝对坐标而localPosition代表相对于父物体局部空间的坐标。很多新手开发者会下意识地使用position来处理所有移动逻辑但在实现层级化的、尤其是需要保持相对稳定关系的跟随系统时localPosition往往能提供更清晰、更健壮的控制逻辑。它帮助我们剥离了父物体运动带来的影响让我们能专注于跟随者与目标之间那层纯粹的、局部的相对关系。接下来我们就彻底拆解如何用localPosition搭建一个既稳定又灵活的跟随系统并附上可以直接“抄作业”的完整代码。2. 核心原理World Position与LocalPosition的本质区别在动手写代码之前我们必须把底层概念吃透否则就是空中楼阁。很多跟随效果出Bug根源就在于对坐标系的理解混淆了。2.1 坐标系分层世界、局部与父子关系Unity场景是一个3D或2D空间它有一个固定的原点(0,0,0)这个空间就是世界空间World Space。任何一个物体GameObject的Transform.position属性描述的就是它在这个世界空间中的绝对坐标。现在假设我们有一个“马车”物体马车里有一个“宝箱”物体。我们把宝箱拖拽成马车的子物体Child。这时宝箱的Transform组件里position显示的还是它在世界中的位置但多了一个localPosition属性。这个localPosition描述的是宝箱相对于其父物体马车的局部空间Local Space原点的偏移量。关键理解局部空间的原点就是其父物体的轴心点Pivot。当父物体马车移动、旋转或缩放时它的局部空间也会随之移动、旋转和缩放。作为子物体的宝箱其世界坐标position会因为这些变换而自动改变以维持它与马车之间localPosition所定义的相对位置关系。但宝箱自身的localPosition值只要你不去修改它就会保持不变。2.2 为何跟随系统常用LocalPosition基于以上原理使用localPosition来实现跟随主要有三大优势解耦层级运动如果目标比如玩家和跟随者比如宠物都有一个共同的、会移动的父物体比如一艘航行中的船那么直接计算它们之间的世界坐标差会很麻烦。而使用localPosition我们可以将跟随逻辑限制在“船”这个局部空间内。无论船开到哪里、怎么摇晃宠物相对于玩家的局部偏移计算都保持不变逻辑清晰且稳定。简化相对位置计算在很多情况下我们关心的不是“宠物在世界中的绝对位置”而是“宠物应该在玩家右后方2个单位处”。这种描述本身就是一种局部空间的关系。直接操作localPosition来达成这种相对关系比先换算世界坐标再计算要直观和准确得多。避免意外干扰如果你用position去强制设置一个子物体的位置你可能会无意中覆盖掉它因为父物体运动而产生的自然变换导致物体“挣脱”了父子关系的约束行为变得诡异。而操作localPosition则是在承认并利用这种父子约束的前提下进行微调更加安全。注意这里存在一个常见的思维误区。当我们说“用localPosition实现跟随”并不意味着跟随者和目标必须是严格的父子关系。实际上它们可以是完全独立的两个物体。这里的“局部空间”是一个逻辑概念。我们可以动态地为跟随者选择一个“参考坐标系”比如目标物体的坐标系然后计算跟随者相对于这个坐标系的期望localPosition最后通过坐标转换将其应用到跟随者的世界坐标上。下文的核心代码将展示这种灵活的应用。3. 方案设计与思路拆解一个完整的跟随效果远不止“移动到目标点”这么简单。它需要处理移动过程使其看起来自然。我们将实现一个功能完备的跟随组件。3.1 核心需求与功能点定义我们的跟随系统FollowerController需要满足以下核心需求基本跟随能够指定一个目标Target并使自身向目标位置移动。平滑移动移动过程应该是平滑的插值Lerp或阻尼SmoothDamp运动而非瞬间跳跃以避免视觉上的突兀感。保持距离可以设置一个期望的跟随偏移量例如跟在目标身后(0,0,-2)的位置而不是重叠在目标身上。独立旋转可选跟随者可以有自己的朝向逻辑比如始终面朝移动方向或者保持独立。空间处理能正确处理世界空间和局部空间的坐标转换这是本教程的重点。健壮性能处理目标丢失、目标禁用等边界情况。3.2 关键技术选型Transform.TransformPoint/InverseTransformPoint这是实现localPosition跟随逻辑的核心API务必理解。Vector3 worldPos targetTransform.TransformPoint(localOffset);作用将一个相对于目标局部空间的坐标localOffset转换到世界空间的坐标worldPos。解读假设localOffset new Vector3(0, 0, -2)这个函数会告诉你在世界中“位于目标正后方2个单位”的那个点到底在哪里。它考虑了目标的位置、旋转和缩放。Vector3 localOffset targetTransform.InverseTransformPoint(worldPos);作用将一个世界空间的坐标worldPos转换到相对于目标局部空间的坐标localOffset。解读给定世界中的一个点这个函数可以算出该点相对于目标来说在其前方、左方还是上方具体偏移是多少。它是TransformPoint的逆运算。我们的核心思路就是我们定义了一个期望的“局部跟随偏移”比如desiredLocalOffset new Vector3(0, 1, -3)表示在目标局部空间中的正上方1米、正后方3米。每一帧我们使用TransformPoint将这个局部偏移转换成世界空间中的目标点targetWorldPosition。然后让跟随者平滑地移动到这个targetWorldPosition。这样无论目标如何旋转、移动跟随者都会稳定地保持在其“后上方”的相对位置上。3.3 平滑移动算法选型Lerp vs SmoothDamp确定了目标点如何让跟随者“平滑”地过去常用两种方法Vector3.Lerp / Vector3.Slerp (线性插值/球形插值)Vector3 newPos Vector3.Lerp(currentPos, targetPos, sharpness * Time.deltaTime);原理按照一个比例t在0到1之间在两个点之间进行线性插值。t越大越接近目标点。通常我们会用一个类似“锐度”sharpness的参数结合Time.deltaTime来动态计算每帧的t实现帧率无关的平滑。特点运动速度在开始时最快接近目标时逐渐变慢。实现简单但速度变化曲线固定。Vector3.SmoothDamp (平滑阻尼)Vector3 newPos Vector3.SmoothDamp(currentPos, targetPos, ref currentVelocity, smoothTime);原理模拟一个带有阻尼的弹簧系统。你需要声明一个Vector3 currentVelocity引用变量来记录当前速度。函数会根据你设定的smoothTime大致到达目标所需的时间自动计算平滑的速度变化。特点运动更加自然像是有物理惯性一样。可以产生“ overshoot”过冲效果通过设置maxSpeed参数更适合模拟摄像机跟随或有机生物的移动感。在本教程中我们将使用SmoothDamp因为它更易控制且效果更佳。Lerp的方案我也会在代码注释中给出供你选择。4. 完整代码实现与逐行解析下面就是完整的FollowerController组件代码。你可以直接创建一个C#脚本将以下代码复制进去挂载到需要跟随的物体如宠物、摄像机、小队成员上。using UnityEngine; /// summary /// 使用localPosition概念实现平滑角色跟随的组件。 /// 挂载在跟随者Follower物体上。 /// /summary public class FollowerController : MonoBehaviour { [Header(跟随目标设置)] [Tooltip(要跟随的目标物体。如果为空组件将不会执行跟随。)] public Transform target; // 要跟随的目标 [Header(跟随偏移 (局部空间))] [Tooltip(相对于目标局部空间的期望偏移位置。例如 (0,0,-2) 表示在目标正后方2个单位。)] public Vector3 desiredLocalOffset new Vector3(0f, 0f, -2f); [Header(平滑移动设置)] [Tooltip(大致到达目标位置所需的时间秒。值越小跟随越紧值越大跟随越松缓且有惯性。)] public float smoothTime 0.15f; [Tooltip(平滑运动的最大速度限制防止极端情况下移动过快。)] public float maxSpeed Mathf.Infinity; [Header(旋转跟随 (可选))] [Tooltip(是否让跟随者也旋转以面向目标或目标移动方向。)] public bool rotateToFaceTarget false; [Tooltip(旋转平滑时间仅当rotateToFaceTarget为true时生效。)] public float rotationSmoothTime 0.1f; // SmoothDamp函数需要的当前速度引用变量 private Vector3 currentVelocity Vector3.zero; private Vector3 currentAngularVelocity Vector3.zero; // 用于存储计算出的世界空间目标位置 private Vector3 targetWorldPosition; void Update() { // 边界检查如果没有设置目标则什么都不做 if (target null) { // 在实际项目中你可以在这里添加日志警告或禁用组件 // Debug.LogWarning(FollowerController: Target is not assigned!, this); return; } // 核心步骤1将期望的局部偏移转换为世界空间的目标点 // TransformPoint 是关键它考虑了目标的位置、旋转和缩放。 targetWorldPosition target.TransformPoint(desiredLocalOffset); // 核心步骤2使用SmoothDamp平滑地移动到目标世界位置 transform.position Vector3.SmoothDamp(transform.position, targetWorldPosition, ref currentVelocity, smoothTime, maxSpeed, Time.deltaTime); // 可选步骤处理旋转跟随 if (rotateToFaceTarget) { SmoothlyFaceTarget(); } } /// summary /// 平滑地让跟随者朝向目标。 /// 这里提供了两种常见的朝向逻辑。 /// /summary private void SmoothlyFaceTarget() { Quaternion targetRotation; // 逻辑A直接看向目标物体本身适合宠物盯着主人 // targetRotation Quaternion.LookRotation(target.position - transform.position, Vector3.up); // 逻辑B看向目标的前方方向适合小队成员看向队长前进的方向 - 更常用 // 我们让跟随者朝向目标的正面方向即target.forward但保持自身的水平Y轴旋转 Vector3 lookDirection target.forward; lookDirection.y 0; // 可选锁定Y轴仅在水平面旋转适用于地面角色 if (lookDirection ! Vector3.zero) { targetRotation Quaternion.LookRotation(lookDirection, Vector3.up); } else { targetRotation transform.rotation; // 如果方向为零向量保持原旋转 } // 使用SmoothDamp的旋转版本进行平滑插值 transform.rotation QuaternionUtil.SmoothDamp(transform.rotation, targetRotation, ref currentAngularVelocity, rotationSmoothTime, Time.deltaTime); } /// summary /// 在Scene视图中绘制Gizmos用于可视化调试。 /// 非常有用可以清楚地看到期望的偏移位置。 /// /summary void OnDrawGizmosSelected() { if (target ! null) { // 计算并绘制目标世界位置 Vector3 gizmoPos target.TransformPoint(desiredLocalOffset); Gizmos.color Color.green; Gizmos.DrawWireSphere(gizmoPos, 0.2f); // 绘制一个绿色线框小球表示目标点 // 从目标位置画一条线回目标物体方便观察偏移关系 Gizmos.color Color.yellow; Gizmos.DrawLine(target.position, gizmoPos); // 如果脚本在运行中可以绘制从自身到目标点的线 if (Application.isPlaying) { Gizmos.color Color.cyan; Gizmos.DrawLine(transform.position, targetWorldPosition); } } } } // 辅助类因为Unity没有内置的Quaternion.SmoothDamp我们需要自己实现一个。 // 这是一个广泛使用的、稳定的实现。 public static class QuaternionUtil { public static Quaternion SmoothDamp(Quaternion rot, Quaternion target, ref Vector3 currentVelocity, float smoothTime, float deltaTime) { // 将四元数差转换为角速度形式进行处理 if (deltaTime Mathf.Epsilon) return rot; Vector3 euler1 rot.eulerAngles; Vector3 euler2 target.eulerAngles; // 处理角度环绕例如从350度到10度 Vector3 delta new Vector3( Mathf.DeltaAngle(euler1.x, euler2.x), Mathf.DeltaAngle(euler1.y, euler2.y), Mathf.DeltaAngle(euler1.z, euler2.z) ); // 对每个欧拉角分量应用SmoothDamp Vector3 smoothDelta Vector3.SmoothDamp(Vector3.zero, delta, ref currentVelocity, smoothTime, Mathf.Infinity, deltaTime); return Quaternion.Euler(euler1 smoothDelta); } }4.1 代码关键点解析desiredLocalOffset参数这是整个系统的灵魂。(0,0,-2)意味着在目标的局部坐标系中跟随点位于Z轴负方向2个单位。如果目标旋转这个“后方”也会跟着旋转。你可以随意调整这个值比如(1, 0.5, -1)让跟随者位于目标的右上方侧后方。target.TransformPoint(desiredLocalOffset)这是最核心的一行代码。它完成了从局部空间期望值到世界空间实际目标点的映射。无论目标怎么动、怎么转这个计算都是正确的。Vector3.SmoothDamp我们用它来实现平滑移动。ref currentVelocity这个参数很重要它由函数内部更新用于维持速度的连续性从而实现惯性效果。务必将其声明为类的成员变量而不是局部变量。旋转处理SmoothlyFaceTarget我提供了两种常见的朝向逻辑。注释掉了“看向目标点”的逻辑启用了“看向目标前方”的逻辑。对于跟随队伍后者更自然。注意我们使用了自定义的QuaternionUtil.SmoothDamp方法因为Unity没有提供原生的四元数平滑阻尼函数。调试Gizmos (OnDrawGizmosSelected)这部分极其重要它会在Unity编辑器Scene视图中当选中跟随者物体时绘制出绿色的目标点desiredLocalOffset对应的世界位置和黄色的连线。这能让你直观地看到“期望的跟随位置”到底在哪是调试偏移量参数最有效的工具。4.2 使用与配置步骤创建脚本在Unity项目中新建一个C#脚本命名为FollowerController用上面的代码完全替换。配置跟随者将脚本挂载到需要执行跟随的物体上例如你的宠物模型。指定目标在Inspector面板中将玩家角色或任何其他物体拖拽到Target字段。调整偏移根据你的游戏设计调整Desired Local Offset。你可以直接在Scene视图中运行游戏然后选中跟随者物体观察绿色的Gizmo小球来实时调整这个向量非常直观。调整平滑度Smooth Time控制跟随的松紧程度。想要紧贴的跟随如第一人称摄像机的头部晃动跟随设为0.05-0.1想要有延迟和惯性的跟随如第三人称摄像机或笨重的宠物设为0.2-0.5或更高。可选旋转如果需要跟随者也面向目标方向勾选Rotate To Face Target并调整Rotation Smooth Time。5. 高级技巧与场景扩展基础跟随实现了但在实际项目中我们往往需要应对更复杂的情况。5.1 应对不同层级的跟随需求我们的核心代码已经通过TransformPoint自动处理了目标的旋转和缩放。但目标的父物体运动呢其实这已经被完美处理了。因为target.position本身就是其世界坐标已经包含了所有层级父物体的变换信息。TransformPoint是基于这个最终的世界变换进行的所以无论目标嵌套在多深的层级里我们的计算都是正确的。一个实践场景你的玩家角色目标站在一个移动的电梯父物体上。你希望宠物跟随者也站在电梯上跟随玩家。这时你有两种选择方案A不设置父子关系就使用我们现在的FollowerController。宠物会根据玩家的世界位置进行跟随。由于玩家在电梯上他的世界位置在变化宠物也会跟着移动效果上是“也在电梯上”。但宠物和电梯没有物理或逻辑关联如果电梯有碰撞体宠物可能会穿模。方案B设置父子关系将宠物设置为电梯的子物体。此时宠物的localPosition是相对于电梯的。你需要修改跟随逻辑计算玩家相对于电梯的localPosition使用InverseTransformPoint然后让宠物的localPosition平滑地接近这个值。这更符合“物体在电梯上”的物理逻辑。如何选择方案A更通用方案B在需要严格物理绑定如平台跳跃游戏时更准确。我们的基础代码是方案A你可以根据需求衍生出方案B。5.2 动态偏移与曲线控制desiredLocalOffset不一定非得是常量。你可以让它动态变化来实现更丰富的效果躲避障碍物当检测到跟随路径上有障碍时可以临时修改desiredLocalOffset.x横向偏移来绕开。队形变换对于多个跟随者如RTS小队可以根据索引号动态计算不同的偏移形成扇形、一字长蛇等队形。平滑过渡使用Vector3.SmoothDamp或Mathf.Lerp来平滑地改变desiredLocalOffset本身实现从“近距离护卫”到“远距离待命”的过渡。// 示例动态切换到侧翼位置 public Vector3 closeFollowOffset new Vector3(0,0,-2); public Vector3 flankOffset new Vector3(3,0,0); private Vector3 dynamicOffset; private Vector3 offsetChangeVelocity; void Update() { if (Input.GetKeyDown(KeyCode.F)) { // 按下F键命令切换到侧翼 dynamicOffset flankOffset; } // 平滑地改变偏移目标 desiredLocalOffset Vector3.SmoothDamp(desiredLocalOffset, dynamicOffset, ref offsetChangeVelocity, 0.5f); // ... 原有的跟随逻辑 }5.3 与Unity导航系统NavMeshAgent结合如果你的跟随者是AI需要使用导航系统寻路那么单纯更新Transform.position会被NavMeshAgent覆盖。正确的做法是控制NavMeshAgent的目的地。using UnityEngine.AI; public class AIFollowerController : MonoBehaviour { public Transform target; public Vector3 localOffset; public float updateDestinationInterval 0.3f; // 降低寻路更新频率优化性能 private NavMeshAgent agent; private float timer; void Start() { agent GetComponentNavMeshAgent(); } void Update() { timer - Time.deltaTime; if (timer 0f target ! null) { timer updateDestinationInterval; // 计算世界空间下的跟随点 Vector3 worldFollowPoint target.TransformPoint(localOffset); // 设置导航目标 agent.SetDestination(worldFollowPoint); } // 注意此时物体的位置由NavMeshAgent控制我们不再直接修改transform.position } }6. 常见问题、调试技巧与性能优化即使代码逻辑正确在实际开发中你还是会遇到各种稀奇古怪的问题。这里记录了我踩过的一些坑和解决方法。6.1 典型问题排查清单问题现象可能原因解决方案跟随者位置抖动或剧烈震动1.SmoothTime值太小过于敏感。2. 目标物体的位置每帧变化巨大如物理模拟的刚体。3. 在FixedUpdate和Update中同时更新位置。1. 增大SmoothTime。2. 确保目标移动是平滑的。对于物理对象可以考虑在FixedUpdate中读取位置在Update中平滑跟随。3. 统一只在Update中处理跟随逻辑。跟随者不跟随或位置错误1.Target未赋值。2.DesiredLocalOffset计算有误或理解错误比如在2D游戏中使用3D偏移。3. 跟随者或目标有父物体且未理解世界/局部坐标转换。1. 检查Inspector或代码赋值。2.使用Gizmos调试选中跟随者看绿色小球是否在预期位置。检查是否为2D游戏应使用Vector2或忽略Z轴。3. 回顾第2章原理理解TransformPoint已处理所有父级变换。旋转跟随时疯狂旋转1. 使用的LookRotation方向向量为零向量或极小。2. 欧拉角环绕从-179度到179度导致SmoothDamp计算路径错误。1. 在计算lookDirection后判断其大小if (lookDirection.sqrMagnitude 0.001f)再调用LookRotation。2. 使用提供的QuaternionUtil.SmoothDamp它内部使用了Mathf.DeltaAngle处理角度环绕比直接Lerp四元数更稳定。跟随延迟感过强像在“飘”SmoothTime值太大或MaxSpeed限制得太低。减小SmoothTime增大或取消MaxSpeed限制。对于需要快速响应的对象如瞄准镜SmoothTime可以低至0.02。在特定角度下偏移错乱目标的缩放Scale不是均匀的(1,1,1)。TransformPoint会受缩放影响。如果目标的缩放是非均匀的如(2,1,1)局部偏移也会被拉伸。确保目标的缩放是均匀的或者在计算偏移时考虑缩放的逆矩阵进行补偿但这属于高级应用通常建议保持缩放为1。6.2 不可或缺的调试技巧善用Gizmos我强烈建议你永远保留OnDrawGizmosSelected里的代码。它是你调试空间关系的“眼睛”。通过调整desiredLocalOffset并实时观察绿色小球的位置你能瞬间理解偏移量的效果。打印关键信息在复杂逻辑中使用Debug.Log输出targetWorldPosition、currentVelocity等变量的值可以帮助你确认计算是否正确。区分Update与LateUpdate如果你的目标是玩家角色且玩家在Update中移动那么跟随逻辑放在Update里可能会有一帧的延迟。对于摄像机跟随通常放在LateUpdate中以确保在所有对象移动完成后再更新摄像机位置这样更平滑。void LateUpdate() { // 将原有的Update逻辑移到这里适用于摄像机跟随 if (target null) return; targetWorldPosition target.TransformPoint(desiredLocalOffset); transform.position Vector3.SmoothDamp(transform.position, targetWorldPosition, ref currentVelocity, smoothTime); }6.3 性能优化小贴士避免每帧查找Target不要在Update里使用GameObject.Find或GetComponent来获取目标。通过序列化字段public Transform target在Inspector中赋值或通过事件/消息在Start/Awake中一次性获取。控制更新频率对于大量非关键的跟随物比如远处的一群鸟不必每帧更新。可以每隔几帧更新一次或者根据距离摄像机的远近来动态调整更新频率。对于大量对象考虑批处理如果你有上百个相同的跟随物如粒子效果、简单的小精灵直接挂载上百个MonoBehaviour并每帧更新Transform开销较大。可以考虑使用一个管理器脚本在Job System或纯循环中批量计算所有对象的位置然后直接设置transform.position。但这属于高级优化范畴在性能出现瓶颈前单个组件的模式是清晰且可维护的。这套基于localPosition概念的跟随系统其核心思想在于通过局部空间来定义相对关系再通过坐标转换到世界空间进行执行。它提供了清晰的逻辑抽象能够优雅地处理旋转、层级等复杂情况。从简单的宠物跟班到复杂的多单位队形管理这个框架都是一个可靠的起点。在实际项目中你可以根据具体需求在上面叠加障碍物回避、动态队形、状态机如“跟随”、“攻击”、“待命”等更复杂的逻辑构建出属于你自己的、富有生命力的AI行为。