角色控制器是物理引擎里少有的"你天天写、但说不清原理"的东西。move_and_slide() 名字起得很直白,实际内部是一次碰撞响应迭代。这篇把源码拆开看:剩下的运动为什么可以分解成滑动分量、循环什么时候停。
一次滑动到底发生了什么
想象你以速度 $v$ 撞上一面法线为 $\hat{n}$ 的墙($\hat{n}$ 指向体外)。如果不做任何处理,本步位移 $v\,\Delta t$ 会把角色推进墙里。滑动的直觉是:把 $v$ 分解成一个"贴着墙走"的部分和一个"扎进墙里"的部分,只保留前者:
$$ v_{\text{slide}} = v - (v \cdot \hat{n})\,\hat{n} $$
这就是向量 $v$ 沿法向的投影消去——剩余的 $v_{\text{slide}}$ 与 $\hat{n}$ 正交(验证:$v_{\text{slide}} \cdot \hat{n} = v \cdot \hat{n} - (v \cdot \hat{n})(\hat{n} \cdot \hat{n}) = 0$,前提是法线归一化)。角色沿墙滑走而不是停下,靠的就是这一个公式。
但"撞上墙"这件事本身要先检测出来。move_and_slide 的做法是:用当前 velocity 对空间做一次形状扫掠(不是射线,是整个 capsule 的形状 cast),找到最近的命中点和法线,然后执行上面的投影。命中点到目标位置之间可能还有剩余路程——扫掠返回的 fraction $t^*$ 告诉你"本步只走完了 $t^*$",剩下的 $(1 - t^*)$ 位移要用削过的速度重走。
为什么是迭代式:一个角不够
单个平面投影已经讲完,为什么还要 max_slides 这个参数?因为真实场景是多个平面的组合。你把速度对第一面墙投影后,沿滑动方向继续前进,可能立刻撞到另一面墙(走廊拐角、楼梯棱、箱子与地面的组合)。此时新的碰撞有自己的法线 $\hat{n}_2$,需要再次投影。
源码里的循环(scene/3d/physics/character_body_3d.cpp 的 move_and_slide)大致是:
while (平台检测 && slide_count < max_slides):
扫掠 velocity * 剩余时间
若无命中:位移完成,退出
有命中:
fraction = 命中比例
移动 fraction * velocity * 剩余时间
剩余时间 *= (1 - fraction)
velocity = velocity - (velocity · n) n # 投影削速
slide_count += 1
记录 floor / wall / ceiling 状态
两个值得注意的细节:
- 每撞一次,剩余时间就缩水一次。三次 slide 后剩余时间可能只剩原来的百分之几,贡献的位移微乎其微——这就是为什么
max_slides设到 4 和设到 8 实际差异不大,循环通常因为"没撞上"而退出,不是因为撞满次数。 - 速度被永久削减。投影改变的是
velocity本身,所以撞墙后的滑动速度自然比撞前慢(斜着撞墙,只有平行分量活下来)。如果你想要"撞墙不减速"的街机手感,得在撞后手动把速度模长补回去,或者用motion_mode调整检测灵敏度。
地板吸附与分离
投影公式对地板同样生效,但角色控制器对地板的态度和墙不同:站在斜坡上,你希望角色贴着坡面而不是每一帧都从坡上"飞"出去一点再落回来。两个机制:
- floor snapping:上一帧判定在地面上时,移动结束后额外向下做一段短程扫掠(
floor_snap_length参数),把角色吸回地面。这掩盖了离散积分在斜坡上天然产生的法向漂移——没有它,走斜坡会一颠一颠。 - floor angle:
floor_max_angle定义了"什么算地面"。这个判断影响is_on_floor()的返回,也影响滑动投影时这面是否被当成可站立面。45° 的斜坡上,法线与竖直夹角 45°,恰好处于默认 45° 阈值的分界上——所以你会发现有的坡能站有的不能,差的就是一度。
和 RigidBody3D 的对比,以及坑
move_and_slide 本质是运动学角色:你直接设置 velocity,引擎帮你扫掠、削速、落地检测,但不参与 solver 的约束迭代(不推箱子、不被弹簧弹开)。这和 RigidBody3D 是两种世界观:前者你控制位置、引擎帮你避开障碍,后者引擎控制位置、你施加力。混合使用时最常见的坑是给 CharacterBody3D 推 RigidBody3D——move_and_slide 的扫掠会直接把刚体顶开而不给对方施加合理冲量,箱子像纸片一样飞,是因为速度突变全算在了刚体的穿透恢复上。
另一个高频坑是 velocity 的单位时间假设:源码按"本帧物理步长 $\Delta t$"消化 velocity,所以 _physics_process 里写的 velocity 必须是"米/秒"而不是"米/帧"。混用渲染帧和物理帧调 move_and_slide 会得到完全错误的位移量——和第 13 篇的固定步长累加器对着看,就明白为什么必须在 _physics_process 里调它。
本文机制基于 Godot 4.x scene/3d/physics/character_body_3d.cpp 的 move_and_slide,形状扫掠经 PhysicsDirectSpaceState3D::cast_motion。