
1. 移动实现前的核心思路拆解1.1 为什么角色移动有“好几种方法”刚接触Unity或者说刚接触C#脚本驱动GameObject的新手最容易困惑的一件事就是网上搜“角色移动”有人用transform.Translate有人改Rigidbody.velocity还有人推荐CharacterController。这几种代码写法完全不一样但好像都能让人物动起来。我当初学的时候也懵了很久后来才慢慢明白这几种方案不是随便选的它们对应的是完全不同的“移动语义”。用现实生活打比方你想把一个纸箱子从房间这头推到那头。直接改transform.position就像是“瞬移”箱子上一秒在左边下一秒出现在右边中间没有过程。用Translate像是在地板上推纸箱每帧推一小段距离但你只顾着推箱子会不会撞到桌子腿、会不会被墙挡住你完全不关心。用Rigidbody像把纸箱放到一个有轮子的板车上你再推板车此时惯性、摩擦力、碰撞反弹都由物理引擎接管。用CharacterController则是给纸箱装了一个看不见的“防撞条”和一个“斜坡检测器”专门为人形角色设计上坡、下坡、被挡住都不会硬穿过去。所以做角色移动的第一步不是抄代码而是想清楚你的角色是什么类型的游戏对象是一颗子弹、一个平台、一个RPG主角还是一个物理小球不同答案对应不同移动方案。1.2 必须先搞懂的两个基础概念Time.deltaTime 和 Update再往下讲之前必须把两个基础概念聊透因为后面所有代码都离不开它们。第一个是Update方法。Unity中C#脚本的生命周期里Update会在每一帧被调用一次。如果你的游戏跑到60帧那么一秒内Update会被调用60次跑到120帧就会被调用120次。问题来了你希望角色一秒移动5米如果直接写transform.position moveDirection * speed相当于每一帧都移动“speed”这么多米。在60帧下一秒移动了speed × 60米在120帧下一秒移动了speed × 120米。同一段代码在不同性能的电脑上角色跑得快慢完全不同。这就叫“帧率相关”。第二个是Time.deltaTime它表示“上一帧到这一帧所经过的秒数”。60帧时它约等于 1/60120帧时约等于 1/120。把Time.deltaTime乘到移动距离里帧率高的每一帧移动得小一点帧率低的每一帧移动得大一点但累加到一起每秒移动的总距离就固定了。所以speed * Time.deltaTime才是“每秒移动speed米”的正确写法。关于Update还要注意一点移动类逻辑放在Update里但物理相关操作比如给Rigidbody施力推荐放在FixedUpdate里。FixedUpdate的调用频率固定默认每秒50次不会因为电脑帧率忽高忽低而改变物理引擎也是按照这个固定步长来计算的。我见过很多新手把AddForce写在Update里结果物体运动的物理表现时好时坏就是这个原因。2. Transform位移组最直接的移动方式2.1 直接给 position 赋值和 Translate 的实战写法Transform类下的移动方式本质上就是修改GameObject的transform.position。最简单粗暴的写法using UnityEngine; public class DirectMove : MonoBehaviour { public float moveSpeed 5f; void Update() { float horizontal Input.GetAxis(Horizontal); float vertical Input.GetAxis(Vertical); // 构造移动方向向量 Vector3 moveDirection new Vector3(horizontal, 0, vertical); // 方法A直接累加position transform.position moveDirection * moveSpeed * Time.deltaTime; } }这段代码意思很直白每帧根据输入得到一个方向向量然后用“方向 × 速度 × 每帧时间”算出这一帧该移动多少距离最后把结果加到当前位置上。稍微进阶一点的是Translate方法void Update() { float horizontal Input.GetAxis(Horizontal); float vertical Input.GetAxis(Vertical); Vector3 moveDirection new Vector3(horizontal, 0, vertical); // 方法B调用Translate默认相对于自身坐标系 transform.Translate(moveDirection * moveSpeed * Time.deltaTime); }Translate的底层逻辑和直接改position本质上是一样的但它有两个小优势第一代码语义更清晰别人一眼就看出这里是在“移动”第二它支持传入第二个参数Space.World或Space.Self用来指定沿着世界坐标轴移动还是沿着物体自身坐标轴移动。比如想让角色始终沿着自己朝向的方向走像第一人称射击游戏用Space.Self想让角色固定沿着地图的X轴Z轴走像俯视角策略游戏用Space.World。2.2 新手练手用的平滑移动写法Lerp 插值位移我还有一招适合新手做“平滑跟随”的写法是用Vector3.Lerp。它每次会根据一个比例把当前位置插值到目标位置。举个例子public class LerpMove : MonoBehaviour { public Transform target; public float smooth 5f; void Update() { // 每一帧都向目标位置靠近一点 transform.position Vector3.Lerp(transform.position, target.position, smooth * Time.deltaTime); } }这个写法的好处是移动过程非常顺滑不会出现瞬移感。smooth值越大跟得越快。但要注意Lerp常用在相机跟随、UI动画、NPC走向目标点这类“不需要严格速度”的场景。如果你让主角也这么移动会感觉角色“软绵绵”的方向键按下去总要慢半拍才到位操作响应不够干脆所以不建议做主角色移动的主方案。2.3 Transform移动的优缺点和适用场景优点很好理解简单、直接、不需要额外的组件你把脚本挂到任何物体上都能动。对子弹、金币、粒子特效、UI图标这类“不需要复杂碰撞”的物体来说这就是最轻量的方案。缺点也很致命完全不考虑碰撞。新手最容易遇到的问题是角色明明设置了Box Collider和Rigidbody结果用Translate一移动直接穿墙了。因为Translate直接改transform.position没有任何物理检测过程碰撞体在这种情况下形同虚设。适用场景推荐非角色类的简单运动门、电梯、平台、子弹UI动画、图片飘动调试阶段快速验证想法不需要与场景复杂交互的非物理物体3. 物理驱动移动Rigidbody的正确打开方式3.1 直接给velocity赋值实用度最高的一种如果你做的游戏需要真正的碰撞、反弹、推动其他物体那就必须让Rigidbody参与移动。物理驱动移动的主流做法有两种先讲使用率最高的velocity直赋值。public class RbMove : MonoBehaviour { public float moveSpeed 8f; private Rigidbody rb; void Start() { rb GetComponentRigidbody(); } void FixedUpdate() { float horizontal Input.GetAxis(Horizontal); float vertical Input.GetAxis(Vertical); Vector3 moveDirection new Vector3(horizontal, 0, vertical).normalized; // 直接设置刚体速度 rb.velocity new Vector3(moveDirection.x * moveSpeed, rb.velocity.y, moveDirection.z * moveSpeed); } }这段代码里有一个非常关键的.normalized作用是把方向向量的长度归一化成1。因为如果同时按住W和D原始输入向量是(1,0,1)长度约为1.414不归一化的话斜着走会比直着走快41%这就是“斜向移动加速”问题新手经常遇到又不知道怎么修。另一个关键点是rb.velocity.y保持不变。因为受重力影响物体在Y轴上本来就有速度你如果直接赋一个全新的三维向量把Y轴写成0会把重力效果清掉物体就飘在空中了。所以只改水平方向的X和Z保留垂直方向的Y这是物理移动的标准写法。用了velocity后角色撞到墙会被墙挡住可以把其他物体撞开移动速度稳定均匀不会出现“推一下冲出去老远”的情况。3.2 AddForce适合做惯性明显的移动另一种物理移动方式是AddForce它的特点是“速度是积累出来的”。每帧给刚体加一个推力物体慢慢加速、慢慢减速。比如操控一辆小车或者一个滑板角色就需要这种惯性手感。public class ForceMove : MonoBehaviour { public float moveForce 10f; private Rigidbody rb; void Start() { rb GetComponentRigidbody(); rb.drag 2f; // 增加阻力避免速度无限叠加 } void FixedUpdate() { float horizontal Input.GetAxis(Horizontal); float vertical Input.GetAxis(Vertical); Vector3 force new Vector3(horizontal, 0, vertical).normalized * moveForce; rb.AddForce(force); } }注意我用了一个rb.drag 2f如果没有这个阻力每次AddForce累加速度最终物体速度会变得极大。drag相当于空气阻力速度越高阻力越大最后会达到一个终端速度手感才受控。AddForce有各种ForceMode枚举默认是持续力还有Impulse瞬间爆发力、Acceleration忽略质量的加速度等。建议新手先把drag和ForceMode.Impulse这两个东西玩明白再根据自己的手感需求微调。3.3 物理移动的碰撞检测配置清单用物理移动时Rigidbody组件的参数配置基本决定成败。我整理了一份清单参数推荐值说明Body TypeDynamic动态刚体才能响应物理InterpolateInterpolate 或 Extrapolate防止画面抖动配合物理更新频率低于渲染频率时很有效Collision DetectionContinuous Dynamic高速移动物体建议开启防止钻过薄墙Constraints勾选 Freeze Rotation X/Y/Z防止角色撞到物体后自己倒下去乱转Drag0 或按需设置用 velocity 时建议0用 AddForce 时按手感加这里着重说一下Interpolate。很多新手做物理移动后发现物体一颠一颠的、肉眼可见地抽搐第一反应是代码写错了其实是因为FixedUpdate的物理更新频率和渲染帧率不同步导致的打开Interpolate后引擎会做插值渲染抖动基本就消失了。这是我带新手时几乎必讲的一条优化项。4. CharacterController人形角色的贴心封装4.1 CharacterController组件的核心特性和适用场景Unity自带一个专门的组件CharacterController从我实际使用的体验来看它给人的感觉是“为人类动作而生的简化版物理系统”。它自带碰撞体但又不完全受物理引擎控制不会自己倒下、不会被外力推走还很智能地处理斜坡、台阶、天花板碰撞。非常适合做第一人称、第三人称角色控制。使用前先给角色挂上CharacterController组件然后通过它的Move或SimpleMove方法移动。注意不需要同时挂Rigidbody这两个最好不要共存否则会互相干扰。public class CCMove : MonoBehaviour { public float moveSpeed 6f; public float gravity -9.81f; private CharacterController controller; private Vector3 verticalVelocity; void Start() { controller GetComponentCharacterController(); } void Update() { float horizontal Input.GetAxis(Horizontal); float vertical Input.GetAxis(Vertical); Vector3 move transform.right * horizontal transform.forward * vertical; controller.Move(move * moveSpeed * Time.deltaTime); // 自己手写重力 if (controller.isGrounded) { verticalVelocity.y -2f; } else { verticalVelocity.y gravity * Time.deltaTime; } controller.Move(verticalVelocity * Time.deltaTime); } }这段代码最大的特点是重力是手写出来的。因为CharacterController不会自动受重力影响所以需要每帧检测isGrounded接地时给一个轻微向下的力保持贴地离地时让Y轴速度不断累加来模拟下落。这个写法一定要记住如果省略重力角色会在空中悬浮。4.2 Move、SimpleMove的区别以及跳跃扩展一个新手高频疑问CharacterController的Move和SimpleMove到底有什么区别SimpleMove(motion)只需要传一个速度向量引擎自动处理重力角色自动接地。适合极简单的移动需求。但它忽略Y轴速度不能做跳跃适合直接拿来跑跑通流程。Move(motion)自己控制一切重力、跳跃、攀爬都由你自己算好方向速度和垂直速度再传进去。灵活度极高是实际项目的正主。如果你想做跳跃只要在接地时给verticalVelocity.y一个向上的初速度即可if (controller.isGrounded Input.GetButtonDown(Jump)) { verticalVelocity.y Mathf.Sqrt(jumpHeight * -2f * gravity); }为什么用Mathf.Sqrt(jumpHeight * -2f * gravity)因为要算出“能让角色跳到指定最高点”的初速度。设定jumpHeight 2gravity -9.81代入公式得到初速度约等于 6.26这样角色就能刚好跳到2米高。重力加速度、跳跃高度、初速度三者的关系就是物理课上学的能量守恒公式套进来反而比“拍脑袋试数字”更可控。4.3 CharacterController挂载后的手感微调经验挂上CharacterController后移动手感可以参考以下几个调整方向Step Offset默认0.1左右这个值决定能自动迈上多高的台阶。设太大角色会“吸”到墙沿上然后滑上去穿模感很强。Slope Limit默认45度。角色走到超过这个角度的斜坡会停住并滑下去。如果做山体地形建议调到60度左右否则很多陡坡爬不上去。Skin Width默认0.08。它像一个“碰撞预判的外套”防止角色卡在两堵墙之间。不建议改太大不然角色看起来没有紧贴地面和地面会有一点点间隙。在实际测试中我建议跑起来后再根据体感微调moveSpeed。CharacterController不涉及物理质量速度传多少就是多少不像Rigidbody那样可能受摩擦影响所以调试起来很直观。5. 输入系统的演进与移动代码的兼容写法5.1 老版Input Manager简单但上限低上面所有代码示例里用的Input.GetAxis(Horizontal)是老版输入系统Input Manager。它在Project Settings的Input Manager里默认配置好了Horizontal和Vertical两个轴映射了键盘WASD、方向键以及手柄左摇杆对新手来说零配置开箱即用。局限性在于可配置项一堆可视化调试体验一般代码层面对“多按键同按”“组合技能”这类需求写得比较繁琐。5.2 新版Input System包推荐新项目尽快迁移Unity把新输入系统Input System做成了PackageUI、配置、设备抽象都比老版好很多但初次接触的配置成本也更高需要新建 Input Actions 资产再绑定动作。核心迁移思路是在 Package Manager 安装Input System重启后选择 New Input System或二者兼容模式但建议直接选新的。创建.inputactions资产添加一个Move动作类型设置为Value绑定控制器的Vector2合成。然后启用资产读取移动向量using UnityEngine; using UnityEngine.InputSystem; public class NewInputMove : MonoBehaviour { public float moveSpeed 6f; private InputAction moveAction; void Start() { var map new InputActionMap(Gameplay); moveAction map.AddAction(Move, binding: Gamepad/leftStick); // 也支持键盘可以在这里补一条binding moveAction.Enable(); } void OnEnable() { moveAction?.Enable(); } void OnDisable() { moveAction?.Disable(); } void Update() { Vector2 input moveAction.ReadValueVector2(); Vector3 moveDir new Vector3(input.x, 0, input.y); // 后续交给CharacterController或Rigidbody处理 } }如果用player输入的鼠标键盘还可以直接用Keyboard.current和Mouse.current读取状态。比如判断按住Wif (Keyboard.current.wKey.isPressed) { // 前进 }新输入系统还有个优势是支持“按需启用/禁用动作”比如打开对话界面时可以直接disable移动动作省得角色在UI打开时还能四处乱跑不需要再靠isActiveAndEnabled这类手动标记去挡开关。新手如果从零开始做新项目我强烈建议直接学新Input System省得以后系统迁移时重写一大片代码。6. 移动实战中避不开的问题排查与优化技巧6.1 新手移动问题速查表我把带新手过程中最常遇到的问题整理成了一个速查表对照着排查比盲目改代码快得多问题表现可能原因解决方案角色穿墙用了Transform移动或碰撞体缺失换成Rigidbody或CharacterController方案角色斜着走快很多方向向量未归一化加.normalized角色没有重力、悬空CharacterController未手写重力用isGrounded配合垂直速度累加角色一碰就倒、乱转Rigidbody未冻结旋转Constraints勾选 Freeze Rotation角色运动一颠一颠FixedUpdate与渲染帧率不同步开启Rigidbody的 Interpolate跳跃高度每次不一样按跳跃键时的帧率不稳定检查是否漏用Time.deltaTime计算重力增量代码里改了速度没效果变量未声明public或私有重复声明统一在类顶端声明或Inspector里调参排查顺序建议是先看组件配置是否齐全有没有Rigidbody/CharacterController再看是不是用了FixedUpdate写物理最后才怀疑代码逻辑本身。大部分新手问题都倒在前两步。6.2 移动相关性能小优化向量缓存与对象池移动逻辑看起来简单但在大量角色同时移动时比如粒子敌人、人群NPC性能也要稍微注意。第一尽量少在Update里创建新变量。虽然现代C#的JIT编译对短生命周期对象优化得很好但new Vector3在移动循环里高频调用会造成不必要的GC垃圾回收压力。移动方向和位置向量建议在循环外部声明循环内只赋值private Vector3 moveDirection; private Vector3 currentVelocity; void Update() { moveDirection.Set(horizontal, 0, vertical); currentVelocity moveDirection * moveSpeed * Time.deltaTime; transform.position currentVelocity; }第二移动物体的物理查询很贵。如果有大量敌人每个敌人每一帧都做一次移动碰撞检测很浪费可以考虑把敌人分帧处理每帧只移动一部分敌人用协程或时间片轮转或者用对象池管理敌人的生成销毁避免频繁Instantiate/Destroy触发的卡顿。这些都是Unity游戏优化的老生常谈但对角色移动来说非常关键特别是做移动端游戏时。6.3 从“能跑”到“手感舒服”我的一些调试心得最后聊聊手感这件事。代码能让角色动起来但要让玩家觉得“这游戏操作舒服”需要做很多细节。我的经验是速度参数尽量拆开调不要只留一个moveSpeed。把地面速度、空中转向速度、加速时间、减速时间分开才能调出有质感的移动。比如用Rigidbody方案时我希望角色起跑时有轻微的“迟钝感”像真人起步有加速过程然后松开按键后快速停下这样才有操控感。可以这样实现一个简单的平滑速度Vector3 targetVelocity moveDirection * moveSpeed; Vector3 smoothedVelocity Vector3.Lerp(rb.velocity, targetVelocity, acceleration * Time.deltaTime); rb.velocity new Vector3(smoothedVelocity.x, rb.velocity.y, smoothedVelocity.z);acceleration越大达到目标速度越快越小滑行感越明显。松手后角色会自然减速因为Lerp始终在向目标速度0靠拢。这个写法比直接vb.velocity target更柔和是这个行业里很常见的“速度平滑”解法新手只要掌握这一个技巧手感就能提升一个档次。就我个人的实操体验来说做移动功能时最难调的根本不是算法而是手感。同一个moveSpeed 8在俯视角游戏和第一人称游戏里体感完全不同所以不要盲目照抄网上的参数自己跑起来看观察起停、转向、斜率变化再一点点改数值直到“手很舒服”。这个过程的本质就是你对deltaTime、物理更新、插值这些基础概念的掌控有多深。先把这几种移动方案的底层逻辑吃透后面做任何类型的角色操控都只是在这个基础上加“花样”而已。