1. 项目起底:第三人称角色移动,到底在解决什么问题
做Unity开发的人,不管你是做动作游戏、RPG还是开放世界探索,绕不开的第一座山就是“让角色动起来”。输入一个方向、角色响应、镜头跟随,这套链路的体验好坏,直接决定玩家在游戏里的第一印象。
第三人称移动和第一人称有个本质区别:第一人称视角下,镜头就是眼睛,你按W就是朝“屏幕里你正在看的东西”走;但第三人称下,镜头悬在角色身后,方向键或摇杆表达的“前进”到底是朝屏幕上方走、还是朝角色正脸朝向走,这里就存在一个坐标系转换问题。很多新手做完移动,发现按W角色往镜头外跑、转身后方向全乱了,原因就是没把输入方向映射到相机坐标空间。
这篇内容适合正在做第三人称项目、但移动手感不理想的人参考,也适合刚学Unity、想系统理解移动实现原理的人。我重点讲CharacterController方案,附上Rigidbody方案的关键对比,把输入、移动、转向、相机跟随、跳跃和地面检测一次讲透。
先说结论:第三人称移动没有标准答案,但有一套稳定好用的经验组合——新Input System采集输入、CharacterController做位移、相机参考系做方向映射、四元数插值做平滑转向。按这套思路,手感不会差到哪里去。
2. 方案选型:为什么我用CharacterController而不是Rigidbody
2.1 两种方案的底层逻辑和真实差异
Unity里让角色移动,主流就两条路:CharacterController(角色控制器)和Rigidbody(刚体物理)。两者看起来都能让人物走起来,但设计初衷和运行机制完全不同。
CharacterController本质是Unity封装好的一个胶囊体碰撞器,带一个Move方法。它不参与物理模拟,不受重力场自动牵引,也不受碰撞反弹影响。你调Move方法给它一个运动向量,它自己会处理碰撞检测、贴着墙壁滑行、落下时你需要手动施加重力,它不会帮你做。
Rigidbody则是完整物理体系的一份子,有质量、受重力、受碰撞反作用力、能被其他物体推走。用Rigidbody移动角色,常见做法是velocity或AddForce,再配合物理材质调摩擦和弹力。
用人话说:CharacterController更像“规则明确的自动电梯”,你告诉它往哪走,它保证不出格;Rigidbody更像“真实世界的箱子”,你推一下它可能滚、可能弹、可能被卡住。
2.2 我踩过的坑和对选型的判断
我最早做第三人称时图省事,直接给角色挂Rigidbody,用velocity控制移动。听起来没问题,结果第一次测试就翻车:角色从斜坡上往下走时被物理引擎的摩擦力搞得到处乱滑,转向时带着惯性漂移,撞到墙上还会轻微反弹,人物贴地感全无。
后来换CharacterController,这些闹心问题全部消失。原因在于物理材质和碰撞响应这些变量被CharacterController内部的碰撞算法消化掉了。它强行把移动控制在“合理”范围内:撞墙不走、不反弹、沿斜坡自动下滑时还会触发坡度限制。
我的判断是:给玩家控制的角色做移动,CharacterController优先,因为手感稳定、行为可预期、调试快。Rigidbody留给AI载具、可被击飞的小型物体、或者你想做非常拟真的物理交互场景。除非你明确要做“被击飞”“被撞击”“堆叠物理”这类效果,否则别拿Rigidbody当移动底座。
2.3 项目环境搭建:新Input System版本与基础配置
现在Unity默认推荐新Input System,因为它在多设备、多平台处理上明显优于老版Input。配置上要分两步:安装包、切换激活方案。
装包直接走Window -> Package Manager,搜索Input System,点Install。装完会弹提示问你要不要激活新方案并重启编辑器,选Yes,然后可以在Project Settings > Player > Active Input Handling确认当前已是“Input System Package (New)”。
如果你的项目里还有老UI事件的代码,切换到新Input System后,旧版Input类API会报错或失效,这条坑我跳过,后面细说。
接下来建的场景结构,我的建议是一辆“空车架构”:场景里空放一个Cube当测试角色,上面挂CharacterController组件,下面挂一个名为PlayerInput的空物体,给镜头单独建一个第三人称摄像机空物体(CameraRig),摄像机作为它的子物体。这套架构后续扩展很顺,角色和相机两个系统互不干扰,后面逐步展开。
3. 核心实现:输入、移动、转向的一条龙拆解
3.1 用新Input System读取输入,写成干净的事件驱动代码
新Input System有两种用法:UnityEvents回调、代码里直接轮询。我两种都用过,推荐代码轮询。写入InputActions资产那套UneityEvents链路,等你项目里输入种类多起来,事件回调会满天飞,排查起来非常煎熬。
代码轮询对应的写法是定义InputAction实例,在OnEnable里Enable,在移动更新的地方读值:
public class PlayerLocomotion : MonoBehaviour { public InputActionReference moveInput; private void OnEnable() { moveInput.action.Enable(); } private void OnDisable() { moveInput.action.Disable(); } private void Update() { Vector2 moveAxis = moveInput.action.ReadValue<Vector2>(); // 然后在下面做移动 } }InputActionReference在Inspector里直接把InputActions资产里对应动作拖进去即可。
这里有个细节:InputActionReference一定要做空值防御。我遇到过一个奇葩场景——某些情况下动作资产没绑好,直接ReadValue会抛异常,导致整个角色控制失效却没有任何报错提示。建议在OnEnable里加个判空,直接return,别让它有机会崩。
3.2 方向向量为什么必须以相机为参考系
这是整个第三人称移动里最重要、也最容易被新手忽略的环节。
你看屏幕,按W,你的直觉是“向屏幕上方走”。屏幕上方即相机正前方的水平投影方向。如果把输入方向直接乘以角色自身的Transform.forward,那么角色只有默认朝向匹配时感觉才对,一旦调转180度,按W角色往镜头方向跑,瞬间体验崩盘。
正解是以主相机的水平朝向为基准,把输入向量(上下左右为两个轴)转换成世界空间里的移动方向。具体逻辑如下:
public Camera playerCamera; Vector3 CameraForward = playerCamera.transform.forward; Vector3 CameraRight = playerCamera.transform.right; // 将相机的方向压平到地面高度,消除俯仰角带来的误差 CameraForward.y = 0f; CameraRight.y = 0f; CameraForward.Normalize(); CameraRight.Normalize(); Vector3 moveDirection = CameraForward * moveAxis.y + CameraRight * moveAxis.x;为什么必须压平Y轴?因为绝大多数第三人称摄像机在追踪时带俯仰角(俯视角色),相机的forward方向是斜向下前方的。如果你不把Y分量清零,直接拿完整forward乘输入方向,移动向量会带向下分量,角色会斜向下走,尤其站在台子上往下走时,速度会莫名变大,非常鬼畜。
压平之后再Normalize,保证移动向量的模长相一致,不然斜着按(比如同时按W和D)时速度会比单独按W快,方向也会偏。
3.3 CharacterController.Move:移动执行的细节与陷阱
拿到方向后,接下来把方向乘以速度,交给CharacterController执行。我直接把移动和重力放在一个方法里,逻辑集中,好排查。
CharacterController controller; public float moveSpeed = 6f; public float gravityMultiplier = 2f; public float moveSmoothTime = 0.05f; private Vector3 currentVelocity; private void HandleMovement() { // 这里moveDirection是上一小节算出来的世界空间输入方向 Vector3 targetVelocity = moveDirection * moveSpeed; targetVelocity.y = currentVelocity.y; currentVelocity = Vector3.SmoothDamp(currentVelocity, targetVelocity, ref smoothVelocity, moveSmoothTime); // 重力叠加 if (!controller.isGrounded) { currentVelocity.y += Physics.gravity.y * gravityMultiplier * Time.deltaTime; } else { currentVelocity.y = -0.1f; } controller.Move(currentVelocity * Time.deltaTime); }SmoothDamp是Unity自带阻尼平滑函数,作用是把当前速度渐变到目标速度,产生一个柔性的起步和停止过程,手感立刻提升一个档次。没有它,你按一下键角色唰地起步、松手唰地停住,像机器人。
smoothTime建议从0.03秒到0.08秒之间调,数值越小反应越快,越大动作越笨重。我常用0.05作为起步值,再根据角色体型和动画微调。
controller.Move里喂的必须是经过Time.deltaTime的位移,而不是速度。这里有个高频错误——直接拿速度乘上deltaTime后方向向量忘了归一化,角色斜着走变快,我之前提过,不赘述。
3.4 朝向旋转:用四元数插值实现顺滑转身
移动不转向就像火车在轨道上滑行,角色脸永远朝一个方向,看着非常怪。第三人称移动里,角色应该面向自己正在移动的方向,但转身不能瞬间跳转,需要一个平滑过渡过程。
做法是:移动输入非零时,记录移动方向,用Quaternion.Slerp让角色的Transform.rotation朝该方向旋转,旋转速度用每帧速度和deltaTime控制。
public bool rotateOnMove = true; public float rotationSmoothTime = 0.08f; if (rotateOnMove && moveDirection.magnitude > 0.1f) { Quaternion targetRotation = Quaternion.LookRotation(moveDirection); transform.rotation = Quaternion.Slerp(transform.rotation, targetRotation, rotationSmoothTime * Time.deltaTime); }Quaternion.LookRotation会把指定向量作为Z轴正前方的朝向,移动方向正是我们期望的角色脸朝向。
rotationSmoothTime的值和移动平滑时间做个呼应比较好,我通常让转向稍微比平移快一点点,比如移动0.05、转向0.08,这样角色身体会微微滞后于运动方向,很像动画里人跑步时上半身比速度方向更晚到位,反而更有生命力。
关于Slerp的速率参数,有人在Update里直接把Time.deltaTime乘固定值,那相当于硬调速度,效果不是百分百平滑。更稳的做法是考虑旋转速度乘deltaTime后做系数,这样不同帧率下表现一致。我自己测试下来,直接用Time.deltaTime乘以旋转灵敏度和用SmoothDampAngle效果差异不大,只要数值适中,玩家根本看不出机制差别。
4. 摄像机跟随:第三人称画面的灵魂环节
4.1 CameraRig的思路:相机不挂在角色身上
很多新手图省事,直接把摄像机做成角色的子物体,拖到头顶后方。这样做会很方便,但后排的代价是:角色转向时摄像机跟着一起转,画面顿时天旋地转,玩家晕得想吐。
正确思路是把摄像机位置和角色位置做解耦,让一个CameraRig空物体独立于角色,相机在这个物体上通过观察角色的位置计算自己的位置,而不是直接寄存角色子级。这样角色的转身、移动不会直接干扰相机朝向。
我的做法:CameraRig挂在场景层,把主角的Transform引用拖进去,LateUpdate里更新。
相机Follow代码的宗旨是:先算期望位置,再平滑过渡,再把相机面向目标。
public Transform target; public Vector3 offset = new Vector3(0f, 2.2f, -4f); public float followSmoothTime = 0.08f; private Vector3 smoothVelocity; void LateUpdate() { if (target == null) return; Vector3 desiredPosition = target.position + offset; Vector3 smoothedPosition = Vector3.SmoothDamp(transform.position, desiredPosition, ref smoothVelocity, followSmoothTime); transform.position = smoothedPosition; // 让相机看向角色头顶上方一点,眼神不呆板 transform.LookAt(target.position + Vector3.up * 1.2f); }为什么放LateUpdate而不是Update?因为Update里角色移动逻辑先跑,相机在LateUpdate再取最终位置来跟随,可以保证当帧的目标位置已经更新完毕,相机不会慢半拍。实际测试里区别非常明显,放Update里相机轻微滞后,快速转身时画面抖得厉害。
4.2 相机穿墙和碰撞:压线视距跟踪
相机跟随最头疼的问题是穿墙。角色贴着墙根走,相机如果按固定offset持续往角色位置上凑,很可能被墙挡住,玩家视角里全是后脑勺和灰墙。
解决这个问题要用到Physics.Linecast或SphereCast,从目标点打一条射线朝相机期望位置方向,如果射线碰到障碍物,就把相机位置压到碰撞点之前最近的点。下面是一个实测好用的简化版:
float cameraCollisionDistance = Vector3.Distance(desiredPosition, target.position); RaycastHit hit; if (Physics.Linecast(target.position, desiredPosition, out hit, obstacleMask)) { Vector3 safePosition = hit.point + (desiredPosition - target.position).normalized * 0.2f; transform.position = safePosition; } else { transform.position = smoothedPosition; }obstacleMask记得设置成只包含场景里真正会挡视线的静态阻挡物。如果不限定Layer,角色自身的碰撞器可能挡住相机,画面不停抖动或者穿模穿出角色体内,都是这只锅。
开射线检测的时机要选对,我测下来在LateUpdate里同一帧处理,比单独一个FixedUpdate稳定,因为LateUpdate紧跟角色位置更新,射线起点即时有效。
尖端细节:墙角边缘的相机容易抽搐。射线从紧贴墙的角色身体上打出来,撞击点反复横跳,导致相机在左右两侧来回弹跳。加一个阈值缓冲,判断碰撞点距离的差值变化小于某个值就维持原位置,能压住一半的抖动。另一方案是加SphereCast替代Linecast,球体半径为0.15~0.3,射线没直接击中边角落,弹跳会缓和很多。
4.3 自由视角旋转:让鼠标控制相机环绕
第三人称游戏如果镜头锁死,玩家探索感会大打折扣。给相机加上)环绕旋转功能几乎是标配。
我的做法是在CameraRig上放鼠标输入侦测,通过水平移动的鼠标位置增量,改变相机y轴的环绕角度。这里做成直角坐标系模式的右手环绕,横轴是旋转的yaw值,竖轴控制pitch的俯仰限制范围,限制角度在指定区间里,防止镜头翻转或钻到地板里。
核心代码逻辑不算复杂:
public float yawSensitive = 1f; public float pitchSensitive = 1f; private float yaw = 0f; private float pitch = 20f; public float minPitch = -30f; public float maxPitch = 60f; void Update() { Vector2 lookInput = lookInputAction.action.ReadValue<Vector2>(); yaw += lookInput.x * yawSensitive; pitch -= lookInput.y * pitchSensitive; pitch = Mathf.Clamp(pitch, minPitch, maxPitch); Quaternion rotation = Quaternion.Euler(pitch, yaw, 0f); Vector3 desiredPosition = target.position - rotation * Vector3.forward * cameraDistance; Quaternion camRot = Quaternion.LookRotation(target.position - desiredPosition); transform.rotation = camRot; transform.position = desiredPosition; }这种方式的核心是把相机的位置从“固定offset”变成“围绕角色旋转”,用旋转后的向量乘以距离得出期望位置,再配合上一节的碰撞挤压,自由视角和防穿墙就兼顾了。
给个经验分界:想省事就固定offset模式,先让移动闭环跑起来;手感问题之后再加自由视角,别一次搞太多变量,否则出了问题不知道从哪排查。
5. 加技能:重力、跳跃与地面检测的进阶处理
5.1 重力与跳起高度:参数计算的数学关系
前面移动代码里已经写了重力的基础版。核心点是:重力加速度并没有直接套用Physics.gravity的默认值,而是乘一个gravityMultiplier,目标是把角色的物件感调重或调轻。
Unity里Physics.gravity默认是(0, -9.81, 0),这是米/立方米级别的物理常数。放在游戏里,如果角色是个忍者或原型英雄,通常希望跳跃更飘渺一点,重力加速度会削弱;如果角色是笨重机甲,就要加大重力系数。
这里有个隐藏参数换算技巧:跳跃高度跟初速度、重力加速度的数学关系是
h = v0² / (2 * g)
反过来,如果你的目标跳跃高度是H,那么需要的初速度V满足:
v0 = sqrt(2 * g * H)
我调参时常用的思路:先定目标跳跃高度,比如1.2米。Unity单位为米,于是v0 = sqrt(2 * 9.81 * 1.2) ≈ 4.85。我把跳跃初速度设成这个值,配合固定重力,角色的跳跃高度就基本准确了。如果做了重力倍率调整,记得算的时候带进去。
落实到代码,跳跃的判断放在HandleMovement之前执行,因为跳跃会改变当前速度的Y分量,移动里再叠加重力。
public float jumpForce = 5f; public float gravityMultiplier = 2f; void HandleJump() { if (jumpAction.action.WasPressedThisFrame() && controller.isGrounded) { currentVelocity.y = Mathf.Sqrt(2f * Physics.gravity.magnitude * gravityMultiplier * targetJumpHeight); } }5.2 别用controller.isGrounded替代一切:地面检测的正确姿势
CharacterController自带isGrounded属性,在Move之后会更新。很多人直接拿来判断是否落地,No,这是个坑。
isGrounded在你的Move方法里如果位移量太小(比如角色正在向上爬坡、速度接近0),返回值可能不准确,甚至会短暂回弹false。在小步幅或缓慢移动时,经常出现角色明明站在地面上,isGrounded却为false,导致重力一直往下加,角色像被吸进地底。
更稳的做法是在角色脚底中心下方发一条短射线(大约脚底向下0.1~0.2米),检测地面碰撞层,用这个结果作为落地的可靠判断源:
public float groundCheckDistance = 0.2f; public LayerMask groundMask; private bool CheckGround() { Vector3 origin = transform.position + controller.center + Vector3.down * (controller.height * 0.5f + 0.1f); origin.y -= 0.1f; return Physics.Raycast(origin, Vector3.down, groundCheckDistance, groundMask); }origin的计算基于CharacterController自身的center和height属性,这样无论你场景里怎么调整胶囊大小,这个检测位置都保持贴近脚底。
用射线检测的另一个好处:你可以把groundCheckDistance调小,角色即将到下坡边缘时检测不到地面,重力立刻接管,角色腾空,做滑落动画时正好用上。
5.3 斜坡处理:坡度限制与动态速度补偿
角色在斜坡上移动是第三人称项目里的潜在车祸区。默认情况下CharacterController自带slopeLimit,默认45度。但我实测下来直接爬大于45度的坡时,角色会一遍一遍地滑下来,甚至出现插进坡里的视觉效果。
动手处理之前先理解怎么工作的:控制器在碰撞时检测到坡面法线与水平面夹角大于slopeLimit,就放弃提供向上位移支持,角色只能沿坡面滑。这不是bug,是设计的防穿坡机制。但在某些梦幻场景(比如溜滑梯、从陡坡跳落),需要更细腻的坡度处理。
在移动向量合成阶段,需要做一次坡向修正。思路是检测移动方向上有没有斜坡碰撞点,如果有就把移动方向顺着坡面方向投影一下,避免角色往坡的垂直方向顶空气。这个逻辑放在Move之前:
Ray slopeRay; Physics.Raycast(origin, moveDirection.normalized + Vector3.down, out slopeHit, 1f, groundMask); if (slopeHit.collider != null && Vector3.Angle(Vector3.up, slopeHit.normal) > 10f) { Vector3 cornerNormal = Vector3.Cross(moveDirection, slopeHit.normal).normalized; moveDirection = Vector3.Cross(slopeHit.normal, cornerNormal); }本质是借助两个向量的叉乘把移动向量投影到坡面上,让角色贴着坡走,而不是硬顶。这个技巧我在做山地地形探索时验过,效果明显。
6. 常见问题与调参心得:一份排查速查手册
6.1 人物无限下落或粘地板
症状:角色一直往下掉,站平台上也不行;或者贴地面无法跳起。
排查:先确认CharacterController是否真被添加且enabled;其次检查重力处理逻辑——如果else分支里没给y一个低于0的收敛值,控制器会瞬时跌落,产生跳动。最容易被忽略的点是地面检测的layer,如果你的地面物体在Default层而groundMask只选了Ground,射线检测永远落空,isGrounded逻辑失效。
6.2 旋转卡顿与转向过度
症状:按方向键角色不是转向过头就是不动。
一般原因是Quaternion.Slerp的t参数配错。假设你写了t = 0.25f,不做乘deltaTime,那每帧旋转综合值跟帧率强绑定——高帧率转得飞快,低帧率转得极慢,手感怎么调都不对。请确保t的计算里含Time.deltaTime或者用固定步长辅以Smooth相关函数处理。
6.3 按键后角色原地不动,但系统没报错
症状:代码全部正常,控制器没反应,或只在一个方向能移动。
多半是输入方向被相机参考系里的Transform算出的方向向量做了一次多余Normalize后变成零向量,尤其在相机刚初始化、forward向量恰好为零时。加个防护:if (moveDirection.magnitude < 0.01f) return;
6.4 摄像机穿透墙后的相机闪烁
症状:贴近墙时镜头反复跳帧、闪屏。
原因大概率是Linecast碰撞点与相机平滑函数之间互相拉扯:平滑试图回到期望位置、碰撞点又把位置压回墙前。你需要在摄像机计算逻辑里把“期望位置”和“碰撞修正位置”做切换时加一个滞回阈值,比如碰撞结果为真时优先用碰撞修正位置,碰到后0.05秒后再平滑过渡回期望位置。
6.5 参数速查表与我的起步配置
下面是我做第三人称移动Demo时常用的一组起步参数,你可以直接抄作业,再根据实际手感微调:
| 参数 | 推荐起始值 | 调节方向 |
|---|---|---|
| 移动速度 | 5~7 m/s | 数值大越敏捷,小越沉重 |
| 移动平滑时间 | 0.03~0.08s | 越小起步越果断 |
| 转向平滑时间 | 0.06~0.1s | 越大转身越钝 |
| 目标跳跃高度 | 1.0~1.4m | 按手感调整 |
| 重力倍率 | 1.8~2.5 | 越大起跳回落越快 |
| 摄像机跟随平滑 | 0.06~0.12s | 越大镜头越飘 |
| 相机offset | (0, 2.0, -4) | 高度影响俯视感,距离影响视野范围 |
| 相机pitch范围 | -20~50 | 限制程度避免镜头反转 |
6.6 动画配合移动的进阶提示
移动系统跑通后,下一步一定要接动画。Animator参数用“Speed”和“TurnAngle”最直接,根据controller.velocity的水平分量大小给Speed,根据移动方向和角色前朝向的夹角给TurnAngle。但注意,如果动画根运动包含了位移,不要和CharacterController.Move同时位移,会角色瞬移或抖动加剧。
我在一个项目里遇到过动画位移和移动逻辑叠加导致角色脚步打滑,明明走得很慢,脚底却滑出半米。排查后发现是把Animator的Apply Root Motion一直开着,角色自身在移动,代码又在推,两股力量叠加。这个坑非常误导人,我建议你在移动逻辑测试阶段先关闭Apply Root Motion,等移动手感稳定后再开,开了之后再做一次双重位移检测。
我自己在实际项目里踩过不少移动相关坑,最深刻的体会是——移动系统不是一个一次成型的东西,它一定是先做减法(逻辑通)、再做加法(动画/物理细节),每加一层就要垫一层测试。整体节奏是:固定相机跟随、调好移动逻辑、再开放自由视角、最后做防穿墙和动画融合。没有这个顺序,问题会纠缠在一起,让你越调越糊涂。
如果你正在做第三人称,建议先搭建完整的逻辑框架,跑通后再回头调每个参数,手感受变化立竿见影。移动系统的核心不是某个环节魔法般好用,而是整个管道大家配合出的手感。希望这组思路能给你一个起点,少走点弯路,早点把精力放到真正有趣的玩法打磨上去。