为什么你的角色控制器感觉像在滑动
当玩家说角色控制器感觉滑溜时,他们实际上在描述四个独立的 bug:刹车、地面检测、坡度处理和插值。
“感觉滑溜”到底意味着什么
去年我对一个第三人称原型进行了一次六人试玩。所有六位玩家都说同一句话:角色控制器感觉像在滑动。没有人能提供更多细节,我在前两天把注意力放错了地方,调试相机平滑和动画混合时间。
问题在我逐帧查看 240 FPS 捕获时显现。玩家松开摇杆后,角色仍继续移动 11 帧,约 180 ms(在 60 Hz 时)。在斜坡上水平速度保持不变,但不断变化的地面法线把身体向前推。在 20 cm 的台阶上,capsule 被卡住并短暂停止。
所以“滑动”并不是单一 bug。减速曲线、地面检测、坡度处理以及物理到渲染的同步各自都有问题,这四个问题一起表现为同一个抱怨。本文将逐一说明我是如何修复它们的。
Rigidbody 还是运动学移动?
最初的实现是经典的 Rigidbody 加 AddForce 组合。理论上看起来合理:引擎负责模拟,你负责施加力。实际操作中,将物理材质摩擦系数设为 0 会导致角色在任何坡面上滑下,而打开摩擦又会在碰到墙壁时瞬间失速。没有一个合适的中间值,因为同一个系数同时作用于地面和墙壁。
我最终采用了运动学移动。Rigidbody 仍然存在,但 isKinematic 被打开;我自行积分速度,并在每个物理步使用 capsule cast 进行扫掠。我也尝试过 Unity 的 CharacterController:它只暴露步高和坡度限制,无法看到其分离行为,并且在下坡时会自行做出决定。
规则变得很简单。碰撞查询交给物理引擎,速度决策交给我自己。CharacterMotor 类大约 300 行,所有运动数值都集中在这里。代价是需要手动编写击退和移动平台的承载逻辑。调节手感时节省的时间远大于此。
最明显的牺牲是失去了与动态刚体的交互。角色不再自行推动箱子,因为引擎不再把它当作质量体。当扫掠碰到动态 Rigidbody 时,我会根据相对速度和质量比施加冲量;这十五行代码恢复了玩家实际能看到的那部分失去的行为。
使用 capsule cast 的地面检测与坡度处理
单条向下射线在边缘处会失效。当角色中心越过平台边缘 3‑4 cm 时,射线会错过,角色会在一帧内被视为空中,下一帧又重新落地。于是我改用与角色半径相同的 CapsuleCast:向下 0.15 m,皮肤容差 0.02 m。
可行性检测读取命中法线的垂直分量:hit.normal.y >= 0.5736f,即 55 度的余弦。为防止在临界点抖动,我加入了滞后,进入着地状态的阈值为 55 度,保持着地状态的上限为 58 度。这个单一比较消除了在陡坡边缘的着地/空中振荡。
在坡面上真正的工作是将速度投影到正确的平面上。直接使用原始水平输入会导致下坡加速、上坡爬行。使用 ProjectOnPlane 对输入向量相对于地面法线进行投影后,行走速度就不受倾斜角影响。我还在下坡时最多向下捕捉 0.3 m 的地面;如果不这么做,每一次下坡过渡都会变成一次小跳。
单次 cast 只返回一次命中,当两块表面相交时法线会在帧间翻转。改用带有八个结果缓冲区的 CapsuleCastNonAlloc 并挑选最陡的可行法线,消除了这种不稳定性。将查询放在单独的层遮罩上也有帮助:排除触发器和伤害体积将 cast 开销降低约三分之一。
步高和沿墙滑动
步爬需要进行三次扫描:向上扫描步高(0.35 m),向前扫描移动距离,然后向下扫描。如果三次都没有碰撞且向下的扫描落在可行走的法线面上,我就把角色体移动到那里;否则取消并将该表面视为墙壁。这三次额外的扫描大约占我测得的每步 0.28 ms 电机开销中的 0.06 ms。
墙壁滑动本身就是碰撞‑滑动循环。在一次碰撞时,我将剩余运动投影到接触平面上并再次扫描。一次投影在内部拐角处不足以解决问题,所以我最多允许四次迭代,如果第四次仍然碰撞则丢弃剩余运动。在迭代次数未受限制的构建中,紧凑拐角的帧时间会飙升至 4 ms。
无论扫描多么小心,角色最终都会漂入几何体,尤其是当移动平台推它时。每个物理步结束时,我使用 Physics.ComputePenetration 测量重叠并将其推出,单步上限为 0.2 m。若不设上限,角色曾因场景中一个负缩放的网格碰撞体而一次性穿墙:测得的重叠超过 3 m。
步高扫描有两个保护。第一是仅在接地时运行;若在空中保持开启,角色会沿着碰到的任何墙壁自行爬升。第二是向上扫描如果碰到天花板则取消整个步高尝试,否则角色会在狭窄走廊中短暂推入天花板。
加速、制动与空中控制
制动是产生滑动感的最直接原因。与其使用 Lerp 将速度拉向目标,我保留独立的速率:地面加速 45 m/s²,制动 60 m/s²,空中 12 m/s²。以 6.2 m/s 的行走速度计算,释放后约 100 ms 可完全停止,接近测试者抱怨的 180 ms 的一半。
制动高于加速是有意为之。若两者对称,角色会显得迟钝;若制动过高,则显得机械。1.3 到 1.5 的比例在我的项目中始终表现良好。我曾尝试将其以 AnimationCurve 暴露给美术;结果并不比两个数值好,且调试更困难。
空中规则不同。我保持动量,不进行制动,以低加速率加入输入,并将水平速度限制在地面最大值。玩家可以在空中转向,但不能通过跳跃获得速度。在一次忘记限制的构建中,测试者通过跳跃‑冲刺组合达到了预期速度的 1.4 倍,并直接冲出关卡边界。
输入端还有两个设置。我将模拟摇杆的死区削减到 0.15,并将其余范围重新映射回 0‑1,否则慢速行走无法实现。我还将面向方向与移动方向分离:角色身体以每秒 720 度的速度转向,而速度向量立即改变,因为将旋转放在速度前会让急转弯感觉延迟。
固定时间步、插值与顺序
电机以 50 Hz 的固定步运行,而显示刷新为 144 Hz。直接在物理步中写入位置,会导致某些渲染帧显示相同姿态两次。由此产生的微抖动会被描述为“卡顿”,大多数人会误以为是帧掉落并去 GPU 分析。
解决办法是保留前后两个物理姿态,并在 Update 中按剩余时间比例在它们之间插值。视觉根节点(即网格和摄像机目标)随插值姿态移动,而碰撞胶囊保持在物理姿态上。在 LateUpdate 中将摄像机绑定到同一插值姿态也很重要:一次帧延迟的读取会让角色看起来在摄像机前漂浮。
固定步的第二个好处是可重复性。因为移动始终经过相同数量的步,相同输入即使在帧时间波动时也会产生相同路径;若没有此机制,跳跃距离在 60 Hz 与 144 Hz 下会相差约 20 cm。长帧到来时,我将累计时间上限设为四步,否则在加载卡顿后模拟永远追不上。
如果你也听到相同的抱怨,请按以下顺序工作:先测量并修复停止时间,然后加入地面捕捉,再加入步高扫描,最后做插值。每个阶段在屏幕上绘制胶囊射线和地面法线;这段可视化代码我花了半天写完,随后三周内一眼就能看到所有 bug。‘感觉在滑动’不是模糊的句子,而是你尚未测量的四个数值之和。