很多刚接触Unity的朋友,第一次让角色动起来的时候都会卡在同一个地方:照着教程把脚本挂上去,按WASD角色确实动了,但总感觉哪里不对劲——要么是斜着走比直着走快,要么是跳起来像在月球上飘,要么是角色贴着墙走的时候疯狂抖动。这几个问题看起来是小毛病,实际上每一个背后都对应着Unity角色控制里一个绕不开的核心机制。这篇内容就把"上下左右移动+跳跃"这件事从头拆一遍,从输入采集、坐标系选择、物理更新时机,到跳跃检测、斜坡处理、空中控制,把每一步为什么这么做讲清楚,最后给出一份可以直接抄作业的完整实现。不管你是刚打开Unity编辑器的新手,还是已经能跑通Demo但想搞明白底层逻辑的进阶者,都能从里面找到对自己有用的东西。
1. 先搞清楚角色到底该用哪种移动方式
在写第一行代码之前,有一个决定比代码本身更重要:你的角色是"直接改位置"还是"交给物理引擎推"。这个选择决定了后面所有代码的写法,选错了后面全是返工。
1.1 Transform位移和Rigidbody位移的本质区别
最直觉的做法是每帧拿到输入,然后直接改transform.position。代码大概长这样:transform.position += moveDir * speed * Time.deltaTime;。这种写法上手极快,角色响应也干脆,但它绕过了物理系统——角色可以穿墙、可以穿过任何碰撞体,因为物理引擎根本不知道你移动了它。对于纯UI演示或者俯视角的格子游戏,这样写没问题;但只要场景里有墙、有台阶、有需要碰撞的东西,这种写法就会立刻露馅。
另一种做法是给角色挂Rigidbody,通过设置velocity或者调用MovePosition来移动。这时候角色的位置由物理引擎在每个物理步长里计算,碰撞检测、重力、摩擦力全部自动生效。代价是你不能随心所欲地瞬移,移动会有一点点"惯性感",而且必须理解FixedUpdate和Update的区别。
我个人的判断标准很简单:角色需要和场景里的碰撞体产生真实互动(撞墙停住、踩台阶、被箱子挡住),就用Rigidbody;角色只是在一个平面上滑动、不需要物理反馈,用Transform就够。绝大多数3D角色控制器都属于前者,所以这篇以Rigidbody为主线来讲,但Transform方案也会给出适用场景。
1.2 三种主流方案的横向对比
实际项目里能见到的角色移动方案基本就三种,我把它们的特性列成表格,方便你对照自己的需求选:
| 方案 | 核心API | 碰撞表现 | 上手难度 | 适用场景 |
|---|---|---|---|---|
| Transform直接位移 | transform.position/transform.Translate | 可穿墙,需自己做检测 | 低 | 俯视角、UI演示、无物理需求 |
| Rigidbody速度控制 | rb.velocity/rb.linearVelocity | 完整物理反馈 | 中 | 3D角色、需要碰撞与重力 |
| CharacterController | controller.Move | 内置斜坡台阶处理 | 中高 | 第一/第三人称角色,不想调物理参数 |
CharacterController是Unity专门为角色移动准备的一个组件,它内部封装了胶囊体碰撞和斜坡处理,用起来比裸Rigidbody省心,但它不参与刚体物理,被其他物体推的时候表现不如Rigidbody自然。新手阶段我建议先用Rigidbody把原理吃透,等理解了速度、重力、碰撞这些概念,再换CharacterController会轻松很多。
提示:如果你在Rigidbody方案里发现角色移动"发飘"、松开按键还会滑一段,多半是没在代码里主动把水平速度归零,而不是物理参数的问题。这个后面会专门讲。
1.3 为什么新手最容易在"速度叠加"上翻车
一个特别典型的翻车场景:角色同时按W和D,斜着走,结果发现斜向速度明显比直线快。原因很直白——输入向量是(1, 0, 1),它的长度是根号2约等于1.414,而你直接拿这个向量乘速度,等于给斜向多加了41%的速度。正确做法是对输入向量做归一化(normalized),保证不管往哪个方向走,速度向量的长度都是1。这个细节几乎所有教程都会提,但真正理解"为什么"的人不多:因为移动速度应该由speed参数唯一决定,输入方向只负责"往哪走",不负责"走多快"。把这两件事分开,代码逻辑立刻就清晰了。
2. 输入采集:从按键到方向向量
输入是整个移动系统的源头,源头处理不干净,后面全是补丁。
2.1 用GetAxis还是GetAxisRaw
Unity的Input.GetAxis("Horizontal")返回的是经过平滑处理的轴值,从0到1会有一个渐变过程,手感偏"柔和";Input.GetAxisRaw返回的是-1、0、1三个离散值,按下就是满值,松开立刻归零,手感偏"干脆"。做平台跳跃类游戏,我一般用GetAxisRaw,因为跳跃游戏讲究操作跟手,按键和角色响应之间不应该有延迟。做赛车或者需要细腻油门控制的场景,才用带平滑的GetAxis。
这里有个容易忽略的点:GetAxis的平滑是Unity内置的,你没法直接调它的曲线,如果觉得默认手感不对,要么换GetAxisRaw自己加插值,要么在项目设置的Input Manager里改Gravity和Sensitivity参数。新手阶段别折腾这些,先用GetAxisRaw把逻辑跑通。
2.2 把两个轴合成一个方向向量
水平输入和垂直输入分别对应世界坐标的X轴和Z轴(注意Unity是左手坐标系,Z轴朝前)。合成方向向量的标准写法:
float h = Input.GetAxisRaw("Horizontal"); float v = Input.GetAxisRaw("Vertical"); Vector3 inputDir = new Vector3(h, 0f, v);这里有个坐标系陷阱:如果你的角色模型朝向不是正Z轴,或者摄像机是斜着俯视的,直接拿这个向量当移动方向,角色会往"世界的前方"走而不是"屏幕的前方"走。解决方式是把这个输入向量转换到摄像机的朝向空间里,后面第4节会详细讲相机相对移动。
2.3 归一化与死区处理
拿到inputDir之后,先判断它是不是零向量,再归一化:
if (inputDir.sqrMagnitude > 0.01f) { inputDir = inputDir.normalized; }用sqrMagnitude而不是magnitude是为了省一次开方运算,虽然在这个量级上性能差异可以忽略,但这是个好习惯。死区阈值设0.01是为了防止手柄摇杆的微小漂移导致角色自己慢慢挪动——键盘不会有这个问题,但如果你后面接了手柄,这个判断就很有必要。
3. 移动实现:速度、加速度与帧率无关
输入方向有了,接下来是把它变成角色真实的位移。这一步的坑主要集中在"帧率"和"物理更新"两个概念上。
3.1 为什么必须乘Time.deltaTime
Time.deltaTime是上一帧到这一帧经过的秒数。如果你的移动代码写成position += dir * speed,那么帧率60的时候角色每秒移动60次,帧率30的时候每秒移动30次,速度直接差一倍。乘上deltaTime之后,移动量变成"每秒speed米",和帧率彻底解耦。这是实时渲染里最基本的概念,但每年还是有大量新手在这里栽跟头。
需要特别注意的是,在FixedUpdate里应该用Time.fixedDeltaTime而不是Time.deltaTime。因为FixedUpdate的调用间隔是固定的(默认0.02秒),用fixedDeltaTime才能保证物理计算的稳定性。虽然很多情况下两者数值接近,但严格来说物理相关的时间增量必须用fixedDeltaTime。
3.2 直接设velocity还是用AddForce
给Rigidbody施加水平移动有两种主流写法。第一种是直接赋值水平速度:
Vector3 targetVelocity = inputDir * moveSpeed; rb.velocity = new Vector3(targetVelocity.x, rb.velocity.y, targetVelocity.z);注意这里保留了rb.velocity.y,也就是垂直方向的速度不受影响,重力照常作用。这种写法响应极快,松开按键角色立刻停住,适合平台跳跃。
第二种是用AddForce施加力,让物理引擎慢慢把角色加速到目标速度。这种写法有惯性,手感偏"重",适合写实风格的角色。但AddForce调参很麻烦,摩擦力、阻力、质量都会影响最终手感,新手很容易调崩。我的建议是:平台跳跃和动作游戏用直接设velocity,写实模拟类才考虑AddForce。
3.3 松开按键后的"滑步"问题
用直接设velocity的方案,松开按键后角色理论上应该立刻停住,因为下一帧inputDir变成零向量,水平速度被设成零。但如果你发现角色还在滑,检查两个地方:一是代码里是不是只在有输入时才更新速度(没有else分支把速度归零),二是Rigidbody的drag(阻力)参数是不是设得太大导致减速有延迟。正确做法是每帧都无条件设置水平速度,有输入就是inputDir * speed,没输入就是Vector3.zero,这样才干脆。
4. 相机相对移动:让"前"真的是屏幕的前
第三人称或者斜俯视视角下,玩家理解的"前"是屏幕的上方,而不是世界的Z轴正方向。如果直接按世界坐标移动,角色会往一个奇怪的方向跑,操作感极差。
4.1 把输入向量投影到相机平面
核心思路是取相机的朝向,把它的前方向投影到水平面上,作为"前"的基准;相机的右方向投影到水平面,作为"右"的基准。然后输入向量就变成forward * v + right * h。代码:
Vector3 camForward = Camera.main.transform.forward; camForward.y = 0f; camForward.Normalize(); Vector3 camRight = Camera.main.transform.right; camRight.y = 0f; camRight.Normalize(); Vector3 moveDir = camForward * v + camRight * h;把Y分量清零是为了防止相机俯视时"前方向"带着向下的分量,导致角色往地里钻。归一化是为了保证投影后的向量长度仍然是1。
4.2 角色朝向与移动方向解耦
移动方向算出来之后,通常还要让角色模型转向移动方向。这里有个经典做法:只旋转模型的Y轴,不旋转碰撞体所在的父物体。因为如果连碰撞体一起转,胶囊体的朝向变化可能影响碰撞检测的稳定性。常见结构是父物体挂Rigidbody和碰撞体负责物理,子物体挂模型负责视觉旋转,用Quaternion.LookRotation(moveDir)配合Slerp做平滑转向。
转向速度用Quaternion.Slerp的第三个参数控制,这个参数是插值比例,配合Time.deltaTime使用时要乘一个转向速度系数,否则帧率变化时转向快慢会不一致。我一般写成Quaternion.Slerp(current, target, turnSpeed * Time.deltaTime),turnSpeed取10左右手感比较跟手。
5. 跳跃:一个看似简单实则暗藏玄机的动作
跳跃是这篇内容里最容易出问题的地方,因为涉及重力、地面检测、输入时机三个系统的配合。
5.1 用冲量还是直接设Y速度
给角色起跳,最直接的写法是把Y方向速度设成一个固定值:rb.velocity = new Vector3(rb.velocity.x, jumpForce, rb.velocity.z);。这种写法起跳高度完全由jumpForce决定,可预测性强,调参直观。另一种是用AddForce(Vector3.up * jumpForce, ForceMode.Impulse)施加一个瞬时冲量,效果类似,但受质量影响,质量越大跳得越低。
平台跳跃游戏我强烈建议用直接设Y速度的方案,因为跳跃高度是核心手感参数,必须完全可控。用冲量的话,哪天你改了角色质量,跳跃高度就变了,排查起来很痛苦。设Y速度时记得保留X和Z分量,否则角色在空中会突然失去水平速度。
5.2 地面检测的三种做法与各自的坑
跳跃的前提是"站在地上",检测方式直接决定了跳跃手感是否可靠。常见做法有三种:
第一种是射线检测,从角色底部往下打一条短射线,碰到地面就认为在地面上。优点是精准、开销小,缺点是对斜坡和台阶边缘不友好,射线可能打空导致误判。
第二种是碰撞检测,用OnCollisionStay判断是否和地面接触。优点是能处理各种形状,缺点是碰撞回调的时机和Update不同步,容易出现"按了跳跃没反应"或者"连跳"的问题。
第三种是球形检测(Physics.CheckSphere),在角色脚底位置检测一个小球范围内有没有地面层物体。这是我最推荐的方案,兼顾了稳定性和对斜坡的容忍度,代码也不复杂。
public LayerMask groundMask; public float checkRadius = 0.25f; public Transform groundCheck; bool isGrounded = Physics.CheckSphere(groundCheck.position, checkRadius, groundMask);groundCheck是一个空物体,挂在角色脚底稍微靠下的位置。groundMask只勾选地面层,避免把角色自己或者道具误判成地面。这个方案的关键是checkRadius和groundCheck的位置要调好,半径太大角色离地还有一段距离就判定为着地,半径太小在斜坡上会误判为悬空。
5.3 跳跃输入丢失与土狼时间
一个非常影响手感的问题是:玩家在落地前一瞬间按了跳跃,但因为当时还没着地,输入被丢弃了,落地后角色站着不动。解决办法是引入"土狼时间"(coyote time)——角色离开地面后的一小段时间内(比如0.1秒)仍然允许起跳。实现方式是记录最后一次着地的时间,起跳时判断当前时间和那个时间差是否在允许范围内。
float lastGroundedTime; float coyoteTime = 0.1f; if (isGrounded) lastGroundedTime = Time.time; if (Input.GetButtonDown("Jump") && Time.time - lastGroundedTime <= coyoteTime) { rb.velocity = new Vector3(rb.velocity.x, jumpForce, rb.velocity.z); }这个机制几乎所有商业平台游戏都有,玩家感知不到它的存在,但没有它就会觉得"跳跃不跟手"。同理还有"跳跃缓冲"——玩家在落地前提前按跳跃,落地瞬间自动执行,这两个机制配合起来,跳跃手感会有质的提升。
5.4 空中控制与重力倍率
角色跳起来之后,空中还能不能改变方向?能改多少?这是手感调校的核心。完全不能改会显得僵硬,完全能改又像在飞。常见做法是空中给一个比地面小的移动系数,比如地面速度是6,空中速度是4,或者空中加速度降低。
另一个技巧是调整重力倍率:起跳上升阶段重力小一点,下落阶段重力大一点,角色会有"跳得高、落得快"的爽快感。实现方式是在FixedUpdate里根据rb.velocity.y的正负动态设置rb.mass或者直接额外施加向下的力。更简单的做法是给Rigidbody设一个基础重力,然后在代码里判断下落时额外加一个extraGravity。
6. 完整实现:一份可以直接跑的脚本
把前面所有内容整合成一个脚本。这个脚本挂在带Rigidbody和Collider的角色上,配合一个脚底的空物体作为groundCheck。
using UnityEngine; [RequireComponent(typeof(Rigidbody))] public class SimpleCharacterController : MonoBehaviour { [Header("移动参数")] public float moveSpeed = 6f; public float airControlMultiplier = 0.6f; public float turnSpeed = 12f; [Header("跳跃参数")] public float jumpForce = 7f; public float coyoteTime = 0.1f; public float jumpBufferTime = 0.1f; public float fallGravityMultiplier = 1.8f; [Header("地面检测")] public Transform groundCheck; public float checkRadius = 0.25f; public LayerMask groundMask; private Rigidbody rb; private float lastGroundedTime; private float lastJumpPressedTime; private bool isGrounded; void Awake() { rb = GetComponent<Rigidbody>(); rb.freezeRotation = true; } void Update() { isGrounded = Physics.CheckSphere(groundCheck.position, checkRadius, groundMask); if (isGrounded) lastGroundedTime = Time.time; if (Input.GetButtonDown("Jump")) lastJumpPressedTime = Time.time; } void FixedUpdate() { HandleMove(); HandleJump(); ApplyExtraGravity(); } void HandleMove() { float h = Input.GetAxisRaw("Horizontal"); float v = Input.GetAxisRaw("Vertical"); Vector3 inputDir = new Vector3(h, 0f, v); if (inputDir.sqrMagnitude > 0.01f) { inputDir = inputDir.normalized; Vector3 camForward = Camera.main.transform.forward; camForward.y = 0f; camForward.Normalize(); Vector3 camRight = Camera.main.transform.right; camRight.y = 0f; camRight.Normalize(); Vector3 moveDir = camForward * inputDir.z + camRight * inputDir.x; float speed = isGrounded ? moveSpeed : moveSpeed * airControlMultiplier; rb.velocity = new Vector3(moveDir.x * speed, rb.velocity.y, moveDir.z * speed); Quaternion targetRot = Quaternion.LookRotation(moveDir); rb.MoveRotation(Quaternion.Slerp(rb.rotation, targetRot, turnSpeed * Time.fixedDeltaTime)); } else { rb.velocity = new Vector3(0f, rb.velocity.y, 0f); } } void HandleJump() { bool canJump = Time.time - lastGroundedTime <= coyoteTime; bool hasBufferedJump = Time.time - lastJumpPressedTime <= jumpBufferTime; if (canJump && hasBufferedJump) { rb.velocity = new Vector3(rb.velocity.x, jumpForce, rb.velocity.z); lastJumpPressedTime = -999f; lastGroundedTime = -999f; } } void ApplyExtraGravity() { if (rb.velocity.y < 0f) rb.velocity += Vector3.up * Physics.gravity.y * (fallGravityMultiplier - 1f) * Time.fixedDeltaTime; } }几个使用要点:groundCheck空物体要放在角色胶囊体底部往下一点点,checkRadius设成比胶囊半径略小;groundMask在Inspector里只勾选地面层;Camera.main每帧调用有性能开销,正式项目里应该在Awake里缓存一次。这份脚本跑起来之后,WASD移动、空格跳跃、相机相对方向、土狼时间、跳跃缓冲、下落加速全部生效,可以直接作为原型的基础。
7. 调参和排错:那些文档里不会写的东西
代码跑通只是开始,真正花时间的是调参和排错。这一节把我踩过的坑和调参经验集中列一下。
7.1 角色抖动与穿模的排查顺序
角色贴着墙走的时候抖动,或者站在斜坡上往下滑,这类问题排查有个固定顺序。先看Rigidbody的Collision Detection是不是设成了Continuous,离散检测在高速移动时会穿模;再看角色的碰撞体是不是和地面有初始重叠,重叠会导致物理引擎每帧往外推,表现为抖动;最后检查freezeRotation有没有勾上,角色被物理引擎转来转去也会导致视觉抖动。
斜坡下滑的问题,可以在角色脚下加一个摩擦力材质,或者用CharacterController替代Rigidbody。如果坚持用Rigidbody,可以在斜坡上时把drag调高,但这是治标不治本,根本方案还是换控制器类型。
7.2 跳跃高度不对怎么调
跳跃高度和jumpForce、重力、fallGravityMultiplier三个参数相关。如果跳得太低,先确认jumpForce是不是被其他代码覆盖了;如果跳起来后下落太快,检查fallGravityMultiplier是不是设得太大;如果跳跃高度不稳定,多半是FixedUpdate里用了Time.deltaTime而不是fixedDeltaTime。调参时建议把fallGravityMultiplier先设成1,把基础跳跃调好,再慢慢加大下落倍率,这样每一步的变化都能感知到。
7.3 输入无响应的几种常见原因
按了跳跃没反应,先确认Input.GetButtonDown("Jump")里的"Jump"在Input Manager里存在(默认是有的,对应空格键);再确认groundCheck的位置和groundMask设置正确,很多时候是groundMask没勾地面层导致isGrounded永远是false;最后检查脚本是不是挂在了一个被禁用的物体上,或者Update被其他脚本的异常中断了。养成在Update里临时加Debug.Log打印isGrounded和输入值的习惯,能省掉大量猜测时间。
7.4 性能上的小优化
Camera.main在Update里每帧调用会做一次查找,虽然Unity有缓存,但正式项目里还是建议在Awake里存成字段。Physics.CheckSphere每帧调用一次开销很小,但如果角色数量多,可以考虑用OnCollisionStay配合计时器替代。另外GetAxisRaw比GetAxis略快,因为不做平滑计算,这也是平台跳跃类游戏偏爱它的原因之一。
8. 从能跑到好用:几个值得加的手感细节
基础移动跳跃跑通之后,如果想让操作感再上一个台阶,有几个成本不高但效果明显的细节可以加。
8.1 落地时的轻微压缩
角色落地瞬间把模型在Y轴上压扁一点点再弹回,视觉上会有"重量感"。实现方式是在检测到从空中变为着地的那一帧,启动一个协程或者用Mathf.Lerp在0.1秒内把模型的localScale.y从0.85恢复到1。这个效果不需要动画资源,纯代码就能做,对原型阶段的观感提升很明显。
8.2 移动时的轻微倾斜
角色加速和转向时让模型稍微倾斜,模拟身体重心的偏移。这个用Quaternion叠加一个小的Z轴旋转即可,倾斜角度和移动速度挂钩。注意倾斜要作用在模型子物体上,不要作用在带碰撞体的父物体上,否则会影响碰撞检测。
8.3 跳跃和落地的音效触发点
音效触发时机比音效本身更重要。起跳音效应该在设置Y速度的同一帧触发,落地音效应该在isGrounded从false变true的那一帧触发,而不是在OnCollisionEnter里触发,因为后者可能在角色蹭墙时误触发。用一个bool wasGrounded记录上一帧的状态,对比当前帧就能精准捕捉落地瞬间。
这些细节单独看都很小,但叠在一起就是"这个游戏操作起来很舒服"和"说不上哪里怪"的区别。我个人的经验是,原型阶段先把第6节的脚本跑通,确认移动跳跃逻辑没问题,再逐个加这些手感细节,每加一个就试玩一次,确保没有引入新的问题。一次性全加上去,出了问题很难定位是哪个细节导致的。
最后分享一个调试习惯:在Scene视图里把角色的groundCheck位置用Gizmos画出来,OnDrawGizmos里画一个和checkRadius一样大的球,运行时能直观看到地面检测的范围和实际着地状态是否吻合。很多"跳跃时灵时不灵"的问题,看一眼这个Gizmo就明白了。