news 2026/10/23 4:14:24

Unity第三人称角色移动系统详解:从输入到相机跟随的完整实现

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Unity第三人称角色移动系统详解:从输入到相机跟随的完整实现

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,等移动手感稳定后再开,开了之后再做一次双重位移检测。

我自己在实际项目里踩过不少移动相关坑,最深刻的体会是——移动系统不是一个一次成型的东西,它一定是先做减法(逻辑通)、再做加法(动画/物理细节),每加一层就要垫一层测试。整体节奏是:固定相机跟随、调好移动逻辑、再开放自由视角、最后做防穿墙和动画融合。没有这个顺序,问题会纠缠在一起,让你越调越糊涂。

如果你正在做第三人称,建议先搭建完整的逻辑框架,跑通后再回头调每个参数,手感受变化立竿见影。移动系统的核心不是某个环节魔法般好用,而是整个管道大家配合出的手感。希望这组思路能给你一个起点,少走点弯路,早点把精力放到真正有趣的玩法打磨上去。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/23 4:07:17

元尚宅乡墅分享农村自建房规划中用地审批与设计建造要点助力农户建安全舒适乡居

农村自建房宅基地使用权申请需满足谁的条件 在农村自建房规划中&#xff0c;宅基地使用权有明确的身份限定。申请人必须是所在村集体经济组织的成员&#xff0c;这是取得用地资格基本前提。已经外迁或者户口落户城市的人&#xff0c;原则上不能再回原宅基地申请建房。符合条件的…

作者头像 李华
网站建设 2026/10/21 17:48:58

计算机组成原理存储系统考点拆解:Cache计算与存储器扩展套路

第三章一直是《计算机组成原理》考试的重灾区&#xff0c;也是拉开分数差距的关键章节。白中英这本教材的第三章《存储系统》更是集概念、计算、设计于一体&#xff0c;很多同学卡在这一章的原因&#xff0c;不是看不懂书&#xff0c;而是看到课后题不知道从哪下手。这篇内容不…

作者头像 李华