1. 项目起底第三人称角色移动到底在解决什么问题做Unity开发的人不管你是做动作游戏、RPG还是开放世界探索绕不开的第一座山就是“让角色动起来”。输入一个方向、角色响应、镜头跟随这套链路的体验好坏直接决定玩家在游戏里的第一印象。第三人称移动和第一人称有个本质区别第一人称视角下镜头就是眼睛你按W就是朝“屏幕里你正在看的东西”走但第三人称下镜头悬在角色身后方向键或摇杆表达的“前进”到底是朝屏幕上方走、还是朝角色正脸朝向走这里就存在一个坐标系转换问题。很多新手做完移动发现按W角色往镜头外跑、转身后方向全乱了原因就是没把输入方向映射到相机坐标空间。这篇内容适合正在做第三人称项目、但移动手感不理想的人参考也适合刚学Unity、想系统理解移动实现原理的人。我重点讲CharacterController方案附上Rigidbody方案的关键对比把输入、移动、转向、相机跟随、跳跃和地面检测一次讲透。先说结论第三人称移动没有标准答案但有一套稳定好用的经验组合——新Input System采集输入、CharacterController做位移、相机参考系做方向映射、四元数插值做平滑转向。按这套思路手感不会差到哪里去。2. 方案选型为什么我用CharacterController而不是Rigidbody2.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.ReadValueVector2(); // 然后在下面做移动 } }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.ReadValueVector2(); 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等移动手感稳定后再开开了之后再做一次双重位移检测。我自己在实际项目里踩过不少移动相关坑最深刻的体会是——移动系统不是一个一次成型的东西它一定是先做减法逻辑通、再做加法动画/物理细节每加一层就要垫一层测试。整体节奏是固定相机跟随、调好移动逻辑、再开放自由视角、最后做防穿墙和动画融合。没有这个顺序问题会纠缠在一起让你越调越糊涂。如果你正在做第三人称建议先搭建完整的逻辑框架跑通后再回头调每个参数手感受变化立竿见影。移动系统的核心不是某个环节魔法般好用而是整个管道大家配合出的手感。希望这组思路能给你一个起点少走点弯路早点把精力放到真正有趣的玩法打磨上去。
阅读完成 · 觉得有帮助?