玩 FPS 你肯定有这体感:斜着撞上一堵墙,人不会当场钉死在那,而是顺着墙面滑走,还能继续往前蹭。这个手感太理所当然了,以至于没人觉得它是个需要设计的东西。但你要是自己写移动,第一版八成就是"撞到就停",玩起来像被墙粘住,难受得要命。
这篇讲的就是从"撞到停死"到"贴墙滑走"这中间发生了什么。这套东西业内叫 Collide-and-Slide,碰撞加滑动,是几乎所有 FPS 角色移动的底层。照旧,全程定点数,坐标单位米。
先看"停死"版本错在哪
最朴素的移动是这样:算出这一帧想去的位置,检测一下路上撞没撞到东西,撞到了就别动。
void Move(ref FixVector3 pos, FixVector3 velocity, Fix dt) {
FixVector3 target = pos + velocity * dt; // 这帧想去的地方
if (SweepHit(pos, target, out var hit)) {
// 撞到了……那就别动了?
return; // ← 停死,罪魁祸首
}
pos = target;
}
问题出在 return 这一下。玩家明明是斜着撞墙的,他的运动里其实一部分是"往墙里钻",另一部分是"沿着墙走"。往墙里钻的那部分该拦,没问题;但沿着墙走的那部分是完全合法的,你一个 return 把两部分一起毙了,人就贴墙上不动了。
所以关键不是"撞到了要不要停",而是"撞到了,把运动拆开,只砍掉不合法的那部分,合法的那部分放行"。
核心就一个动作:把速度拍到墙面上
想通了上面那点,解法就浮出来了。撞墙的时候,墙会给你一个法线——就是垂直于墙面、指向外面的那个方向。玩家的速度向量,可以沿着这个法线拆成两块:
墙面法线 N
↑
| 玩家速度 V
| ↗
| ╱
─────────┼──────╱──────── 墙面
| ╱
| ╱
|╱
V 拆成两块:
一块顺着 N(钻进墙里的分量)—— 要砍掉
一块沿着墙面(滑动的分量) —— 要保留
怎么拆?靠点积。速度 V 在法线 N 上的投影长度,就是 Dot(V, N)。这个值代表 V 有多少是朝着法线方向去的——也就是有多少在往墙里钻。
拆出来之后,把"钻墙"那部分从 V 里减掉,剩下的自然就是"沿墙滑"那部分:
// 把速度投影到墙面上,去掉钻进墙里的分量
static FixVector3 ProjectOnPlane(FixVector3 v, FixVector3 normal) {
Fix dist = FixVector3.Dot(v, normal); // v 有多少朝着法线方向
return v - normal * dist; // 减掉这部分,剩下的贴着墙面
}
就这三行,是整个滑动手感的心脏。normal * dist 是"钻墙"分量,v 减掉它,留下的就是平行于墙面的滑动速度。这个操作数学上叫"把向量投影到平面上",名字唬人,干的事就是"把往墙里去的劲儿抽掉"。
这里要提醒一句,Dot 内部是三个分量各自相乘再相加,每个乘法都是定点乘法,中间值会暴涨,前面反复讲过的那个 64 位防溢出的坑,这儿照样存在。你的 FixVector3.Dot 底层必须把中间乘积提到 long,不然大坐标下点积直接溢出,滑动方向瞬间乱套。
一次滑动的完整流程
有了投影,把移动逻辑重写一遍:
void Move(ref FixVector3 pos, ref FixVector3 velocity, Fix dt) {
FixVector3 remaining = velocity * dt; // 这帧还剩多少位移要走
if (SweepHit(pos, pos + remaining, out var hit)) {
// 走到撞墙点,先把撞之前那段路走完
pos = hit.point;
// 剩下没走完的位移,拍到墙面上,改方向继续滑
FixVector3 leftover = (pos + remaining) - hit.point;
remaining = ProjectOnPlane(leftover, hit.normal);
// 顺便把速度本身也拍一下,不然下一帧又直直撞上来
velocity = ProjectOnPlane(velocity, hit.normal);
}
pos += remaining;
}
流程说白了三步:走到撞墙那一点停下,把没走完的路折向墙面,顺着新方向把剩下的路走完。速度也得跟着拍一下,否则这帧滑走了,下一帧速度还是原来那个直冲墙的方向,又撞上,白忙活。
一次滑动不够,要能连着滑
上面这版有个隐患。你斜着撞墙滑走,滑着滑着可能又撞上另一堵墙——比如墙角。这时候你只做了一次投影就 pos += remaining 收工,那第二堵墙就漏检了,人会直接穿进墙角里。
所以真正的 Collide-and-Slide 是循环的:滑动之后再检测,又撞到就再滑,直到这帧的位移全部走完,或者撞够了次数为止。
void Move(ref FixVector3 pos, ref FixVector3 velocity, Fix dt) {
FixVector3 remaining = velocity * dt;
const int MAX_SLIDE = 4; // 最多滑 4 次,够用且防死循环
for (int i = 0; i < MAX_SLIDE; i++) {
if (!SweepHit(pos, pos + remaining, out var hit)) {
pos += remaining; // 一路无阻,走完收工
return;
}
pos = hit.point; // 走到这次的撞墙点
FixVector3 leftover = (pos + remaining) - hit.point;
remaining = ProjectOnPlane(leftover, hit.normal); // 折向墙面
velocity = ProjectOnPlane(velocity, hit.normal);
}
// 滑够 4 次还在撞(墙角、夹缝),剩下的位移就不走了,防止卡进几何体
pos += remaining;
}
MAX_SLIDE 这个上限很重要。墙角、V 字形夹缝这种地方,你滑向 A 墙又撞 B 墙,滑向 B 墙又撞 A 墙,不设上限能无限循环下去,一帧卡死。设个 4 次,既够处理绝大多数复杂几何,又能兜住死循环。撞满 4 次还在撞,说明玩家钻进了个特别刁钻的角落,这时候宁可让他停一下,也不能让游戏卡住。
为什么这套东西非定点不可
你可能想,滑动手感是表现层的东西,用 float 算算得了,反正玩家又看不出那点误差。
在竞技 FPS 里这想法要命。角色位置是权威状态,它要参与服务器校验、参与延迟补偿回放、参与命中判定。你在客户端用浮点滑出来一个位置,服务器用浮点滑出另一个略微不同的位置,两边一对不上,轻则位置回弹(玩家看着自己被拽回去,俗称橡皮筋),重则整局 desync。
而滑动这套逻辑里全是点积、向量减法、乘法——正好都是定点数逐比特一致的强项。同样的输入、同样的墙、同样的法线,红米和 iPhone 和服务器滑出来的位置,连最低位都一样。这样服务器校验才有意义,延迟补偿倒带才能精确复现"你当时到底贴在墙的哪个位置"。
这也是为什么滑动逻辑必须整个待在定点模拟层里,连那个 ProjectOnPlane 都不能图省事转 float 算。一处泄漏,满盘皆输——这话在这个系列里说过好几遍了,但每一处都是真金白银的教训。
几个手感上的延伸
基础的贴墙滑讲完了,顺带说几个让手感更真实的常见处理,不展开写代码,给你个方向:
地面和斜坡。走斜坡本质也是滑动,只不过法线朝上。你走上一个缓坡,速度被投影到坡面上,人就顺着爬上去了;坡太陡,投影完向上的分量太小,人就爬不动往下出溜——滑动这套逻辑天然就把斜坡处理了,不用额外写。
台阶。想让玩家能自动迈上小台阶而不是被矮墙挡住,得在撞到矮障碍时先试着抬高一点再往前探,探到了就踩上去。这叫 step offset,是 Collide-and-Slide 之上加的一层。
保留下落。人在空中斜撞墙,水平方向该被墙挡住并滑走,但竖直方向的重力下落不该被影响。所以有些实现会把水平滑动和竖直下落分开算,别让投影把重力也给抹了。
收尾
回到最开始那个问题:为什么撞墙不停死、要滑走?
因为玩家斜着撞墙时,他的运动里"往墙里钻"和"沿墙走"是两码事。停死是把两者一起毙了,粗暴。正确做法是用点积把速度沿墙面法线拆开,只砍掉钻墙那部分,保留沿墙那部分——这就是 ProjectOnPlane 那三行干的事。再套上一个"滑完再检测"的循环处理墙角,配一个次数上限防死循环,一套顺滑的角色移动就成了。
而这一切之所以能放心用,是因为底下全是定点数的点积和向量运算,逐比特一致,服务器和每个客户端滑出来的位置分毫不差。手感是玩家感受到的,确定性是玩家感受不到、但一旦缺了就满屏橡皮筋的东西。好的移动手感,就是这两样在底下悄悄咬合的结果。


被折叠的 条评论
为什么被折叠?



