1. 项目概述为什么新手总在移动上栽跟头刚接触Unity的新手尤其是想复刻《原神》这类开放世界角色移动手感的开发者几乎都会在角色移动方案上踩一遍坑。最常见的场景就是跟着教程给角色挂上一个Rigidbody刚体组件加上Character Controller脚本满心欢喜按下播放键结果角色要么像抹了油一样滑来滑去要么卡在斜坡上动弹不得要么就是和场景物体发生诡异的“抽搐式”碰撞。折腾半天游戏没做出来血压先上来了。这背后的核心矛盾在于物理驱动的Rigidbody与游戏逻辑驱动的角色移动本质上是两套不同的设计哲学。Rigidbody是物理引擎的“公民”它遵循牛顿力学受重力、摩擦力、碰撞冲量影响行为是模拟真实物理的、间接的。而《原神》这类游戏的角色移动是高度风格化、响应迅速且完全受玩家输入控制的它需要的是“确定性”和“操控感”而不是物理真实性。用Rigidbody去实现就像用方程式赛车的引擎去拉货——不是动力不行是根本不对路。CharacterController组件就是Unity为这种“游戏逻辑驱动”的角色移动量身定制的解决方案。它本质上是一个胶囊体碰撞器加上一套专用的移动逻辑。它不参与物理引擎的力与速度计算只响应你通过代码Move方法直接下达的位移指令并在移动过程中进行碰撞检测与解决。这意味着角色的移动完全由你的代码逻辑决定你可以精确控制加速、减速、转向、跳跃的每一帧表现从而复刻出《原神》中那种流畅、跟手、能在复杂地形上稳定行走的移动体验。简单来说如果你的角色需要像真实物体一样被踢飞、滚动、受复杂外力影响用Rigidbody。如果你的角色需要像大多数RPG、ACT游戏的主角一样拥有稳定、可靠、响应即时的移动CharacterController才是你的首选。接下来我们就彻底拆解如何用CharacterController避开所有新手坑实现一套《原神》级别的移动控制系统。2. CharacterController核心机制深度解析要驾驭CharacterController不能只停留在调用Move函数的层面必须理解其内部工作机制和关键参数这些参数直接决定了手感的“调性”。2.1 核心参数手感调校的六脉神剑CharacterController的Inspector面板上有几个关键参数每一个都深刻影响着移动行为Slope Limit坡度限制单位是度。它决定了角色可以行走的最大斜坡角度。例如设置为45度那么超过45度的斜坡角色将无法上行并会从坡上滑落。在《原神》中角色可以攀爬陡峭的山坡但并非所有角度都能上去这个参数就是第一道关卡。新手坑设置过大如90度可能导致角色在垂直面上“行走”设置过小则连一个小土坡都上不去。建议初始值设为45-60度根据游戏世界地形调整。Step Offset台阶偏移单位是米。这是实现“自动踏上低矮台阶”功能的关键。当角色前方有一个高度小于等于此值的障碍时CharacterController会自动将角色移动上该台阶而不会卡住。这是实现平滑地面移动体验的灵魂参数。《原神》中角色流畅地走上楼梯、石块全靠它。新手坑值设得太高角色可能会“穿模”或突然被抬升到意想不到的高度设得太低则会被很小的地面凸起卡住。一般设置在0.3到0.5米之间是合理的。Skin Width皮肤宽度单位是米。这是最容易被误解但至关重要的参数。你可以把它想象成胶囊体碰撞器外的一层“弹性外皮”。它的作用是防止角色因为浮点数精度问题而与其他碰撞器发生“抖动”或“卡死”。当两个碰撞器非常接近时这层“皮肤”会提供一个微小的重叠缓冲让碰撞检测更稳定。新手坑设置过小如0.001会导致角色在复杂地形上疯狂抖动或卡进地面设置过大则会让角色感觉“浮”在地面上或者很早就触发碰撞。一个经验法则是将其设置为Radius的10%左右。例如半径为0.5米Skin Width可设为0.05米。Min Move Distance最小移动距离单位是米。这是一个性能优化参数。如果单帧计算的移动向量长度小于此值则本次移动会被忽略。这可以避免因为极其微小的位移如浮点误差而持续进行昂贵的碰撞检测计算。新手坑对于需要极其精细移动如潜行游戏的情况此值不能设得太大否则会出现“走不动”的情况。通常保持默认值0.001即可。Center、Height、Radius中心、高度、半径这三个参数共同定义了胶囊体碰撞器的形状。Center决定了碰撞体相对于角色模型Pivot点的偏移通常需要根据模型脚底位置调整确保胶囊体底部与模型脚底对齐。Height和Radius则决定了角色的“物理体积”。新手坑直接使用默认值导致角色碰撞体与视觉模型严重不符表现为“悬空”或“半身入地”。务必根据角色模型的实际尺寸进行调整确保碰撞体紧密包裹住角色。2.2 Move vs SimpleMove理解移动的两种范式CharacterController提供了两个移动方法它们的区别是另一个关键的知识点Move(Vector3 motion)这是最核心、最强大的方法。它接受一个Vector3作为位移向量并立即尝试应用这个位移。关键点在于它不自动应用重力。这意味着垂直方向Y轴的移动完全由你的代码控制。你需要自己计算并累加重力速度velocity.y gravity * Time.deltaTime然后将包含Y轴速度的向量传递给Move。这给了你完全的控制权可以实现自定义的重力、多段跳、空中控制等复杂行为。《原神》中角色跳跃、下落、攀爬的细腻手感必须基于Move方法来实现。SimpleMove(Vector3 speed)这是一个简化版的移动方法。它接受一个表示水平速度的Vector3注意Y轴分量会被忽略。它的特点是自动处理重力角色会始终被拉向地面。但它返回一个布尔值告诉你角色是否着地且无法直接控制垂直速度。它适用于非常简单、不需要复杂跳跃逻辑的移动。新手坑试图用SimpleMove来实现跳跃会发现根本跳不起来或者无法控制跳跃高度和下落速度。核心结论要实现《原神》式的丰富移动必须使用Move方法并手动集成重力与跳跃逻辑。SimpleMove只适合原型验证或极其简单的场景。3. 构建《原神》式移动控制器从零到一理论说完了我们动手搭建。目标创建一个手感扎实、支持奔跑、跳跃、重力、斜坡和台阶处理的移动控制器。3.1 基础框架与输入处理首先创建一个C#脚本比如AdvancedCharacterController.cs挂载到你的角色GameObject上。确保该GameObject已经添加了CharacterController组件。using UnityEngine; [RequireComponent(typeof(CharacterController))] public class AdvancedCharacterController : MonoBehaviour { private CharacterController controller; private Vector3 playerVelocity; // 当前帧的速度向量包含垂直速度 private bool groundedPlayer; // 上一帧是否着地 private float playerSpeed 5.0f; // 基础行走速度 private float runSpeed 8.0f; // 奔跑速度 private float jumpHeight 1.5f; // 跳跃高度 private float gravityValue -9.81f; // 重力加速度 private void Start() { controller GetComponentCharacterController(); // 初始化时确保角色有一个向下的速度以便能立刻检测到地面 playerVelocity.y gravityValue * Time.deltaTime; } private void Update() { HandleMovement(); } private void HandleMovement() { // 1. 检测是否着地 groundedPlayer controller.isGrounded; if (groundedPlayer playerVelocity.y 0) { // 如果着地且垂直速度向下将其重置为一个很小的负值确保紧贴地面 playerVelocity.y -0.5f; } // 2. 处理水平移动输入使用新的Input System或旧的Input.GetAxis Vector2 moveInput new Vector2(Input.GetAxis(Horizontal), Input.GetAxis(Vertical)); Vector3 moveDirection new Vector3(moveInput.x, 0, moveInput.y); // 将输入方向从本地空间转换到世界空间使移动相对于角色的朝向 moveDirection transform.TransformDirection(moveDirection); // 3. 判断是否奔跑 float currentSpeed Input.GetKey(KeyCode.LeftShift) ? runSpeed : playerSpeed; moveDirection * currentSpeed; // 4. 应用水平移动 controller.Move(moveDirection * Time.deltaTime); // 5. 处理跳跃仅在着地时允许跳跃 if (Input.GetButtonDown(Jump) groundedPlayer) { // 根据物理公式 v sqrt(2 * g * h) 计算起跳初速度 playerVelocity.y Mathf.Sqrt(jumpHeight * -2f * gravityValue); } // 6. 应用重力 playerVelocity.y gravityValue * Time.deltaTime; // 应用垂直方向的速度重力跳跃 controller.Move(playerVelocity * Time.deltaTime); } }代码解析与避坑isGrounded的延迟controller.isGrounded反映的是上一帧Move调用后的着地状态。这意味着你在当前帧检测到isGrounded为true后执行跳跃逻辑上是正确的。重置垂直速度当角色着地时groundedPlayer playerVelocity.y 0我们将playerVelocity.y设为一个小的负值如-0.5f而不是0。这是为了确保角色持续对地面施加一个微小的压力让isGrounded检测在斜坡和边缘上更加稳定。如果设为0在斜坡边缘可能会因为单帧未检测到碰撞而错误地进入坠落状态。跳跃速度计算我们使用公式v sqrt(2 * g * h)来计算达到指定跳跃高度h所需的初始垂直速度v。这是一个标准的匀加速运动公式能保证跳跃高度的一致性。两次Move调用注意我们先调用controller.Move应用水平移动再计算和调用一次controller.Move应用垂直速度。理论上可以将水平位移和垂直位移合并到一个向量里在一次Move调用中完成。但分开写逻辑更清晰也便于后续扩展如区分水平碰撞和垂直碰撞的处理。3.2 实现平滑的镜头跟随与角色转向《原神》的移动是角色朝向与移动方向解耦的你可以用左摇杆控制移动方向同时用右摇杆控制镜头和角色面向。我们需要拆分移动方向和朝向。public class AdvancedCharacterController : MonoBehaviour { // ... 之前的变量 ... public Transform cameraTransform; // 赋值主摄像机的Transform public float rotationSmoothTime 0.1f; // 角色转向平滑时间 private float turnSmoothVelocity; // 平滑转向的当前速度 private void HandleMovement() { // ... 着地检测、速度重置代码不变 ... // 获取原始输入 float horizontal Input.GetAxisRaw(Horizontal); float vertical Input.GetAxisRaw(Vertical); Vector3 direction new Vector3(horizontal, 0f, vertical).normalized; // 只有有输入时才计算转向和移动 if (direction.magnitude 0.1f) { // 计算目标旋转角度基于摄像机朝向将输入方向转换为世界方向 float targetAngle Mathf.Atan2(direction.x, direction.z) * Mathf.Rad2Deg cameraTransform.eulerAngles.y; // 平滑地旋转角色到目标角度 float angle Mathf.SmoothDampAngle(transform.eulerAngles.y, targetAngle, ref turnSmoothVelocity, rotationSmoothTime); transform.rotation Quaternion.Euler(0f, angle, 0f); // 根据目标角度计算出世界空间下的移动方向 Vector3 moveDir Quaternion.Euler(0f, targetAngle, 0f) * Vector3.forward; float currentSpeed Input.GetKey(KeyCode.LeftShift) ? runSpeed : playerSpeed; // 应用移动 controller.Move(moveDir.normalized * currentSpeed * Time.deltaTime); } // ... 跳跃和重力应用代码不变 ... } }关键点Input.GetAxisRaw使用Raw版本获取未经平滑处理的原始输入-1 0 1响应更迅速更适合动作游戏。基于摄像机的移动通过cameraTransform.eulerAngles.y将输入方向与摄像机朝向结合实现“无论镜头怎么转前推摇杆永远是向屏幕上方移动”的直觉操作。Mathf.SmoothDampAngle这是一个极其好用的函数用于平滑角度插值。它避免了直接使用Lerp可能带来的旋转抖动问题能自动处理360度环绕让角色转向看起来自然流畅。rotationSmoothTime参数控制转向的“惯性”大小值越大转向越慢越平滑。3.3 高级手感打磨加速度、空中控制与 Coyote Time基础移动有了但手感还显生硬。接下来我们加入一些高级技巧。1. 平滑的加速度与减速度直接给速度会让移动显得很“硬”。我们可以引入一个当前水平速度的引用并逐渐向目标速度逼近。private Vector3 currentHorizontalVelocity; // 当前的水平速度 public float acceleration 10f; // 加速度 public float deceleration 15f; // 减速度 private void HandleMovement() { // ... 之前的代码计算目标移动方向 moveDir 和目标速度 targetSpeed ... // 计算目标速度向量 Vector3 targetVelocity moveDir * targetSpeed; // 平滑地改变当前速度 if (targetVelocity.magnitude 0.01f) { // 有输入加速 currentHorizontalVelocity Vector3.Lerp(currentHorizontalVelocity, targetVelocity, acceleration * Time.deltaTime); } else { // 无输入减速至零 currentHorizontalVelocity Vector3.Lerp(currentHorizontalVelocity, Vector3.zero, deceleration * Time.deltaTime); } // 应用平滑后的水平速度 controller.Move(currentHorizontalVelocity * Time.deltaTime); }2. 空中控制Air Control在跳跃腾空后完全失去水平控制会显得很僵硬。我们可以允许角色在空中也有一定的控制能力但通常弱于地面。public float airControlFactor 0.5f; // 空中控制系数0为无控制1为完全控制 private Vector3 airVelocity; // 记录起跳时的水平速度 private void HandleMovement() { // ... 在跳跃触发时记录起跳速度 ... if (Input.GetButtonDown(Jump) groundedPlayer) { playerVelocity.y Mathf.Sqrt(jumpHeight * -2f * gravityValue); airVelocity currentHorizontalVelocity; // 记录起跳时的速度 } // ... 在计算目标速度时根据是否在空中进行插值 ... Vector3 targetVelocity; if (!groundedPlayer) { // 空中允许部分控制基于当前输入和起跳速度 Vector3 airTargetDir (moveDir * targetSpeed) * airControlFactor; targetVelocity Vector3.Lerp(airVelocity, airTargetDir, 0.1f); // 缓慢地向输入方向调整 } else { // 地面正常计算 targetVelocity moveDir * targetSpeed; } // ... 后续的平滑加速减速逻辑 ... }3. Coyote Time土狼时间这是一个经典的手感优化技巧当角色从平台边缘跑出去后在极短的时间窗口内如0.1-0.2秒按下跳跃键依然能执行跳跃。这避免了玩家因帧数或反应问题导致的“明明感觉踩到了却跳不起来”的挫败感。private float coyoteTime 0.15f; private float coyoteTimeCounter; private void UpdateCoyoteTime() { if (groundedPlayer) { coyoteTimeCounter coyoteTime; // 着地时重置计时器 } else { coyoteTimeCounter - Time.deltaTime; // 空中时递减 } } private void HandleMovement() { UpdateCoyoteTime(); // 跳跃条件改为按下跳跃键且着地 或 在土狼时间内 if (Input.GetButtonDown(Jump) (groundedPlayer || coyoteTimeCounter 0)) { // ... 执行跳跃 ... coyoteTimeCounter 0; // 跳跃后立即清零防止连续触发 } }4. 地形与碰撞处理告别抖动与卡顿CharacterController虽然省心但在复杂地形和密集碰撞体中配置不当依然会导致各种诡异问题。4.1 斜坡与台阶的行为调校斜坡滑落当角色站在一个角度大于Slope Limit的斜坡上时CharacterController会自动让其沿斜坡滑落。这个行为是内置的你无法直接通过参数关闭但可以通过检测controller.isGrounded和速度向量来控制。如果你不希望滑落可以考虑在代码中当检测到角色在斜坡上且没有输入时将其速度归零但这可能违反物理直觉。台阶处理Step Offset是处理低矮障碍的利器。但要注意它只在角色向前移动时生效。如果你的角色是侧向或后退撞上台阶则不会触发自动攀爬。对于复杂的移动如攀爬、跳跃你需要编写更专门的逻辑。边缘卡住有时角色会在两个斜坡的交界处或凸起边缘卡住。这通常是因为Skin Width设置过小或者胶囊体的Radius相对于地形缝隙过大。适当增加Skin Width并确保角色碰撞体尺寸合理可以缓解此问题。4.2 处理ControllerColliderHit消息当CharacterController在Move过程中发生碰撞时会发送OnControllerColliderHit消息。这是你处理角色与场景物体交互如推开箱子、踩碎罐子的关键入口。private void OnControllerColliderHit(ControllerColliderHit hit) { // hit.normal: 碰撞点的法线向量 // hit.moveDirection: 角色发生碰撞时的移动方向 // hit.gameObject: 被碰撞的物体 // 示例如果撞到的是带有“Pushable”标签的物体且碰撞来自侧面则施加一个力 Rigidbody rb hit.collider.attachedRigidbody; if (rb ! null !rb.isKinematic hit.gameObject.CompareTag(Pushable)) { // 计算推力的方向水平方向排除垂直分量 Vector3 pushDir new Vector3(hit.moveDirection.x, 0, hit.moveDirection.z); // 施加一个力 rb.AddForceAtPosition(pushDir * 5f, hit.point); } // 示例防止角色爬上过陡的斜坡虽然Slope Limit已处理但这里可以做额外逻辑 float slopeAngle Vector3.Angle(Vector3.up, hit.normal); if (slopeAngle controller.slopeLimit) { // 可以在这里播放一个“滑落”的动画或音效 } }重要提示OnControllerColliderHit在每次Move调用且发生碰撞的帧都会被调用可能很频繁。避免在这里进行昂贵的计算或每帧都实例化对象。4.3 与其它碰撞体的交互Trigger与Rigidbody与Trigger交互CharacterController本身不会触发OnTriggerEnter等消息。如果你需要检测角色进入某个区域如伤害区域、存档点你有两种选择为角色额外添加一个普通的Collider如胶囊碰撞器并设置为Is Trigger用它来检测Trigger。注意调整这个Trigger碰撞体的大小和位置避免与CharacterController的碰撞体冲突。在OnControllerColliderHit中检查hit.collider.isTrigger但这种方法只能检测到非运动学刚体上的Trigger且逻辑复杂不推荐。与动态Rigidbody交互CharacterController可以推动带有Rigidbody的物体如上例。但反过来动态的Rigidbody无法推动CharacterController。因为CharacterController不受物理力影响。如果你需要让场景中的物体如被爆炸炸飞的箱子能击飞角色你需要自己写代码在检测到足够强的碰撞时手动计算一个位移向量并调用controller.Move或者临时为角色添加一个Rigidbody来处理这段被击飞的动画之后再切换回来。5. 性能优化与常见问题排查5.1 性能注意事项单角色使用CharacterController设计上是为单个玩家或NPC角色使用的。对于大量需要移动的实体如一群怪物使用它会带来较大的CPU开销。对于大量实体应考虑使用Rigidbody简单的力模拟或更高级的ECS/DOTS方案。Complex Collision Meshes复杂碰撞网格让CharacterController在非常复杂的网格碰撞体上移动如一个高精度的岩石模型会比在简单的Primitive立方体、球体、胶囊体上移动消耗更多性能。尽量使用简化的碰撞体来代表复杂地形。Min Move Distance合理设置此值默认0.001可以过滤掉大量无意义的微移动检测提升性能。5.2 常见问题速查与解决方案问题现象可能原因解决方案角色抖动或卡进地面Skin Width设置过小角色初始位置嵌入地面。增大Skin Width如设为Radius的10%。确保角色初始位置略高于地面。角色感觉“浮空”或下楼梯时踏空Skin Width设置过大Step Offset可能也有影响。减小Skin Width。检查Step Offset是否过高。确保Center和Height正确使胶囊体底部与脚底对齐。无法走上低矮台阶Step Offset值小于台阶高度。适当增大Step Offset值如0.3-0.5米。确保移动方向是正对台阶的。在斜坡上自动滑落即使坡度很小Slope Limit设置过小或重力gravityValue过大。增大Slope Limit。检查重力值是否合理-9.81是真实重力游戏中可以调小如-15到-25以获得更快的下落。跳跃高度不一致或无法跳到预期高度跳跃速度计算错误重力应用时机问题。使用公式initialJumpVelocity Mathf.Sqrt(2 * jumpHeight * -gravityValue)。确保重力在Update中每帧累加并在Move中应用。移动有延迟手感不跟手使用了Input.GetAxis而非Input.GetAxisRaw速度平滑加速度时间过长。移动输入使用GetAxisRaw。调整acceleration和deceleration参数减小平滑时间。角色能穿过薄墙或门Skin Width过大导致“渗透”单帧移动距离过大。减小Skin Width。确保每帧的移动向量 (speed * Time.deltaTime) 不会过大如果速度极高可以考虑使用Clamp限制最大帧位移或使用FixedUpdate进行物理移动。OnControllerColliderHit不触发碰撞体是Trigger碰撞体是静态网格且未勾选Generate Colliders移动向量为0。确保碰撞体不是Trigger。对于静态模型确保其导入设置中勾选了生成碰撞体。确保Move方法被调用且位移向量不为零。5.3 一个调试技巧可视化CharacterController在Scene视图中CharacterController的碰撞体在未选中时是不可见的这给调试带来了困难。你可以编写一个简单的编辑器脚本来始终绘制其Gizmos。using UnityEngine; [RequireComponent(typeof(CharacterController))] public class AdvancedCharacterController : MonoBehaviour { // ... 所有之前的变量和代码 ... // 在Scene视图中绘制Gizmo private void OnDrawGizmosSelected() { CharacterController cc GetComponentCharacterController(); if (cc ! null) { Gizmos.color Color.green; // 绘制胶囊体。Unity没有直接绘制胶囊体的Gizmo函数我们用两个半球一个圆柱体来近似。 Vector3 center transform.position cc.center; float height cc.height; float radius cc.radius; // 绘制中间圆柱体 Vector3 topCenter center Vector3.up * (height / 2 - radius); Vector3 bottomCenter center - Vector3.up * (height / 2 - radius); Gizmos.DrawWireSphere(topCenter, radius); Gizmos.DrawWireSphere(bottomCenter, radius); // 这里可以进一步绘制连接线更直观的方法是使用Handles库但需要放在OnDrawGizmos中且条件更复杂。 // 一个简单的替代在Inspector中选中角色CharacterController组件会高亮显示其碰撞体形状。 } } }实际上更简单的方法是在Play模式下直接在Hierarchy中选中你的角色对象然后在Scene视图里你就可以清晰地看到CharacterController胶囊体的绿色线框轮廓这对于实时调试位置、大小和碰撞情况至关重要。走到这里你已经拥有了一个手感扎实、功能完整的《原神》式移动控制器核心。它响应迅速、支持奔跑跳跃、能平滑处理斜坡台阶并且避免了Rigidbody带来的各种物理模拟难题。记住所有优秀的角色移动都不是一蹴而就的需要根据你的游戏具体手感是偏厚重还是偏灵巧反复调整重力、加速度、跳跃力、平滑时间等参数。最好的方法就是一边调参一边在你自己搭建的测试场景包含平地、斜坡、台阶、凹凸地形里反复跑跳直到找到那个让你觉得“对了”的感觉。这之后你就可以在此基础上继续添加冲刺、攀爬、游泳等更复杂的移动状态构建出真正属于你自己的开放世界。