02 实体移动过程及碰撞机制
1 基于 Entity.move() 方法
首先需要说明,这是几乎所有实体(包括玩家、除恼鬼外大部分生物、盔甲架、交通工具、TNT、物品、落沙、烟花火箭、鱼竿浮标和被活塞及潜影盒推动时的各种实体等)共有的运动形式,通用性极强,所以很重要,有些内容可能不很好懂,但最好还是了解一下。部分实体重写了该方法,如潜影贝重写了该方法使其在被潜影盒推动时尝试传送。
此处输入位移均指作为参数传到Entity.move()方法中的位移向量。
研究相关源代码(net.minecraft.entity.Entity:517),可知这种实体移动过程如下,其中 2-4 步合称 "位移趋势预处理",输入位移(即作为参数传入该方法的位移,也称位移趋势)经预处理得到 "实际位移趋势":
- 若实体的 noClip 属性为 true,直接移动碰撞箱及坐标,跳过其它步骤(518-520)
- 若移动源为活塞,进行相关调整(在
adjustMovementForPiston()方法中定义,主要为限制位移和坐标轴)(522-527) - 尝试应用蛛网和浆果丛对本次移动的减速作用(529-534)
- 进行潜行相关调整(在
adjustMovementForSneaking()方法中进行,默认为不调整,潜行且着地的玩家在处理自身 Motion 产生的移动时会避免从高度大于 stepHeight,即 0.6m(客户端)或 1m(服务端)的边缘掉落)(536) - 对方块,世界边界和固体实体的碰撞检查(
adjustMovementForCollisions())(537)- 初步检查(709)
- 能直接移上去的方块相关的修正(714-727)
- 如果剩余的位移趋势大于m,移动碰撞箱和坐标(需注意,碰撞箱和坐标无直接关系)(538-541)
- 更新 horizontalCollision(水平碰撞),verticalCollision(垂直碰撞),onGround 状态(545-547)
- 进行摔落相关运算(此处由实体的
fall()方法定义)默认如下(548-550)- 若已落地且 FallDistance > 0, 进行摔落伤害运算并置零 FallDistance(916)
- 否则,增加 FallDistance(921)
- 将 X、Z 轴中发生碰撞(实际位移未达到实际位移趋势)的轴上 Motion 归零,1.18.2-Pre1 前,若两轴都有碰撞,则恢复 X 轴上的 Motion(552-558)
- 若发生竖直方向碰撞(顶头或落地)则进行实体着陆方块(见第 5 节开头)的 onEntityLand () 方法定义的一个操作(560-567)
- 大部分方块(包括空气)默认为置零 y 轴 Motion(
Block.java:374)
- 大部分方块(包括空气)默认为置零 y 轴 Motion(
- 若着地且未潜行,则尝试产生下方方块在实体从上方经过时的效果(调用对应方块的
onSteppedOn()方法)(565-567)- 默认无操作,其它方块如红石矿石进行了重写(Block.java:335)
- 行走声音相关(569-597)
- 检查方块网格碰撞(
checkBlockCollision())(600) - 下方方块的减速(影响 Motion)作用(608-609)
- 此时若在火或熔岩中,则被引燃(610-619)
现在,这里主要讨论 5 和 13,余下需要讲解的内容将在后文中讲解。
图 4.1.1 碰撞检查中实体移动路径示意图
在 5 中,游戏首先会生成一个可能的碰撞范围,包括路径附近的方块、世界边界(仅在实体于边境内时)和固体实体(仅包含不直接或间接共同骑乘统一实体的实体)。然后,游戏会尝试进行一次初步的碰撞检查,即检查实体在按沿各轴方向运动时有没有发生碰撞。与实体碰撞箱相交的物体会被排除,这意味着实体在卡到方块和固体实体内部时可以轻松地出来,但不能重新进入。检查一个轴的过程大概可以理解为为:从某一起点 A(原坐标或上一个轴的终点)在对应轴上将坐标加上输入位移趋势在该轴上分量的数值得到另一点 B,检查实体碰撞箱从 A 到 B 运动过程中有无碰撞,若有,将碰撞点作为该轴的终点和下个轴检查的起点,否则将 B 作为该轴的终点和下个轴检查的起点。
在 1.14 及以后,沿轴检查时,Y 轴总是被最先检查,然后是 XZ 轴中实际位移趋势绝对较大的轴(若相等,则是 X 轴),最后是余下的一个轴;1.14 之前则总是按 YXZ 的顺序进行。这一改动会使一些本来具有方向性的设备不再依赖朝向或者直接失效。例如,假设存在一款想沿轴方向发射 TNT 的 TNT 炮,但由于只进行了在两侧用几个方块阻挡炮弹点燃时的随机运动的矫正所以推进方向会略微偏离预计的沿轴方向。那么在 1.14 前,这一推进速度偏差在预计的方向所在轴为 Z 轴时会因为炮弹首先尝试沿 X 轴检查碰撞并撞到方块被消除,但所在轴方向为 X 轴时这一推进速度偏差则一般不会被消除。在 1.14 之后,由于游戏可以判断出发射方向并先沿这个方向进行碰撞检查,所以这一推进速度偏差无论目标方向在哪个轴上都不会被消除。
图 4.1.2 实体沿轴碰撞检测路径
可以这样理解,虽然一次移动从坐标变化的角度看来是瞬时的,但是这种移动从碰撞判定的角度却完全可以分成三次独立的沿轴移动。也就是说,如图 4.1.1 所示,这种实体移动过程中可以认为实体是沿白线而非蓝色直线运动的,假如选取大小合适的实体,再赋予合适的速度,实体可以在图 4.1.2 中管道内部(金块标注的部分,实际上应为空气)由下向上穿过管道。
图 4.1.3 实体直接移上方块相关调整示意图
然后,如果实体在移动前着地,或在移动中 Y 轴唯一向下且发生了竖直方向上的碰撞,游戏会进行实体直接走上方块的相关调整,大概就是在保证不会出现实体走过去高度(顶部 Y 坐标与实体原坐标的 Y 坐标的差值)大于 stepHeight 的障碍和穿过小于实体碰撞体积的洞的现象且水平位移不多于输入位移的情况下尽可能让水平位移更远,并在不使实际 Y 轴位移多于输入趋势的情况下的让实体尽可能着地。好吧,通俗地完全解释还是比较难的,大家直接按经验来也好,不过还是给出一些例子,图 4.1.3 中红框和蓝框为移动前后的实体碰撞箱,灰色部分是固体方块,绿色实线标明了实体的 stepHeight,绿色虚线标明了实体一次最高能移上的高度,紫色实线标明了实体的实际位移趋势。前三幅都较好理解,第四幅有时可能算是一个 Bug,需要注意。理论上,所有实体都可以有这个能力,但大多数实体的 stepHeight 被设为 0 从而没有外在表现,未被骑乘或未被控制的猪和未被骑乘或未被控制的赤足兽为 0.5,可以迈上或被推上半个方块;客户端玩家和大部分生物则是 0.6,可以迈上或被推上部分不完整方块和船;末影人、溺尸、铁傀儡、马类(HorseBase 及其子类)、服务端玩家(目的可能是实现被活塞推上方块的机制)、被骑乘且被控制的猪和被骑乘且被控制的赤足兽为 1,可以迈上或被推上完整方块。
此处给出直接走上方块这一机制的实现代码以供参考(Entity.adjustMovementForCollisions():675-685):
脚手架和熔岩有一些特殊机制,这使得它们对不同种类或状态实体可以有不同的碰撞箱。脚手架倒好说,估计大家都试过了。对于熔岩,这一设定使它对赤足兽有了一个 0.5m 高的碰撞箱,这是为了使赤足兽可以稳定地走在上面。
1.18 前,在碰撞运算中,流体会被忽略,这使路径上的流体不会对该实体产生影响,除非在移动结束后实体仍有部分碰撞箱与其相交。1.18 及之后,一个 raycast 被加入到 move () 方法中用于摔落伤害的计算(这使得 move () 方法也会加载沿途区块),但变速等影响仍不会发生。
固体实体是一类完全阻挡部分实体移动的实体,目前对大多数实体存在固体性的实体主要有船和活的潜影贝。除了它们以外,isPushable()方法返回 true 的实体(包含矿车、船、鹦鹉、未被骑乘的马类、活的不是旁观者而且未处于可攀登方块所在方块网格内的大部分 LivingEntity)对矿车和船也是固体的。另外,共同直接或间接地骑乘同一个实体的实体间相互不表现出固体性。
图 4.1.4Creeper 举例图
第 13 步中,如果与实体的碰撞箱从各方向向内缩 0.001m 后的区域相交的方块网格所在区块均被加载,实体会尝试调用那些方块网格中的方块定义的onEntityCollision()方法,以实现与相交的方块的交互,如气泡柱变速、仙人掌的伤害运算、末地传送门的传送和促使绊线检查是否被触发(只是促使它检查是否被触发,真正触发还需要它的检查范围内,也就是所在方块内部最下方 0.15625m 内同时存在有效实体碰撞箱)等。这一运算被称为检查方块网格碰撞。检查顺序为从西北到东南,从下到上,但多数情况下不必关心。具体实现代码可以参考Entity.java:835,checkBlockCollision()。举个例子,图 4.1.4 中的苦力怕站在这四个铁块的正中央,绿色线框是它的碰撞箱,红色玻璃标记的 8 个方块网格都与苦力怕的碰撞箱相交,黑色数字标明了对它们进行检查的顺序;铁块所在网格范围在微量缩小后并不与苦力怕相接触,认定为不相交。
检查方块网格碰撞这一操作在 1.21.3 前后被从基于 Entity.move() 的移动过程中,并放置在了实体自身的运算流程当中,不在与移动绑定。与此同时,传统意义上的这一过程在这一版本中也被完全重写,虽然在 1gt 内位移低于 0.99999m 时,除了 “碰撞箱从各方向向内缩 0.001m” 中的数字应改为以外这一机制似乎没有发生明显的变化,但在牵扯到较高速度的运动时,检查范围却发生了很大的变化。这时,实体移动起止点之间的直线上附近的方块也会被检查,而且检查的线段长度会被限制在 16m 左右的数量级。确切来说应该是,实体会取上一次被强加载时碰撞箱的西北偏下顶点指向新碰撞箱的这一顶点的一条有向线段,然后在其两端延长其长度的倍,并沿该线段进行一次不一定完整的 raycast(详见下一节),找到射线与其经过的各个方块网格边界靠近起点位置的交点,钳位使得这些交点到方块网格侧面边缘的距离不超过,最后假设实体碰撞箱从各方向向内缩得到的立方体范围的西北偏下顶点在这些交点处,并检查与这一系列范围相交的方块网格中的方块。过程中,实体只会截取至多 16 个大致与起点最近的顶点以减少性能开销。
2 基于 raycast 的弹射物自主移动
这一类移动方法中决定运动的碰撞判定部分主要用于除鱼竿浮标和烟花火箭外的弹射物的自主移动,烟花火箭虽然在决定运动碰撞判定中使用Entity.move()方法,但是在确定究竟撞到了什么方块或实体时仍会使用这种碰撞检查过程。
raycast是指检查一个点沿一个有向线段移动的过程中是否发生或者发生了怎样的碰撞的过程。在解释 Minecraft 中的 raycast 前,要先引入 "方块网格" 的概念。
图 4.2.1 方块网格示意图
在一个维度中,每个方块坐标对应一个方块网格,一个方块网格范围内各点对应方块坐标相等,或者说将各轴坐标向下取整得到同一个点的所有点对应一个方块网格。一个方块网格大小等于一个完整方块,内部只能有一个方块,没有任何点不属于任何一个方块网格,也没有任何点属于多个方块网格。图 (4.2.1) 中的玻璃方块占据了一个方块网格,方块内部以及标红的面(西面、北面和下面,不计边界)、棱(与西北偏下的顶点直接相邻的棱,不计端点)和西北偏下顶点属于该方块网格,其它面、棱和顶点不属于该方块网格,各轴正方向在图中以 F3 界面下准星的形式指出。
图 4.2.2 raycast 示意图一
图 4.2.3 raycast 示意图二
在对于方块的 raycast 的过程中,原线段向两端延长一个微小值(小到可以忽略,即每一端延长总长度的倍)后得到的新线段经过的所有方块网格中的方块会被依次检查。
比较严格地来讲,目前版本中,对单个被检查的方块的 raycast 主要有以下特点:
(1) 如果线段上距起点最近的一千等分点在方块碰撞箱中(与方块网格类似,此处的碰撞箱范围只包括内部、西面、北面、下面、与西北偏下的顶点直接相邻的棱,或西北偏下的顶点,均不计边界与端点),游戏认定发生了碰撞
(2) 将方块碰撞箱分割为若干个轴对齐边界箱,如果一个点沿着线段从起点移向终点的过程中从任意轴对齐边界箱外部通过了任意一个边界箱上任意一个与线段对应的向量分量绝对值大于的轴垂直且在轴上相对于想对面的位置与起点相对于重点的位置相当的面向平行于该面的两个轴的正负方向分别扩大后得到的矩形,游戏认定发生了碰撞。
当然这样讲显然比较难懂,在不计微小偏差且位移不太大的情形下,也可以认为这些特性与下面的规则大致等价:
(1) 线段在方块内部或穿入了由方块分割出的立方体的表面,游戏认定发生了碰撞
(2) 如果线段的起点或终点在方块表面而且其他部分都在方块外部,游戏会认定碰撞没有发生
(3) 如果线段经过且只经过了其表面,只有经过坐标值较小的面、棱或顶点时(类似方块网格),游戏认定发生了碰撞
(4) 如果线段起点终点重合或近似重合,游戏总会认定碰撞没有发生
(5) 如果线段没有经过该方块的任何部分,游戏会认定碰撞没有发生
至于方块碰撞箱的分割,情形比较复杂,而且会有方向性。大致的流程是,将方块分割为尽可能较少的多个正方体体素(立体像素),然后从碰撞箱中坐标值最小(按 X、Y、Z 的顺序比较)的一个体素开始,合并 Z 轴正方向上与该体素相邻而连续的若干个体素得到一个最长的条状区域,接着在 X 轴两个方向上延伸该条状区域得到一个最大的面状区域,最后在 Y 轴的两个方向上延伸得到的面状区域,得到一个被分割出的轴对齐边界箱并在碰撞箱中去除该区域后开始下一轮。一个结论是,长方形的方块碰撞箱只能分割出一个与原碰撞箱相同的边界箱。
举个例子,图 4.2.2 中的线段以铁块顶端作为起点,竖直向上擦过钻石块和绿宝石块的表面,到达较起点高 3.5m 的终点。在这个过程中,线段经过的是钻石块的南面,不属于钻石块所在的方块网格,所以钻石块并不会被纳入检查范围;线段经过了绿宝石块的北面,也就是说经过了绿宝石块所在的方块网格,所以绿宝石会被检查,线段在绿宝石块的下方就被阻挡了。图 4.2.3 中绿色(棱为蓝色)方框标明了被检查的方块所在网格的轮廓,黄色方框标明了被检查方块的碰撞箱(若有),青色线条是有向线段中未被阻挡的部分,红色线条是有向线段中被阻挡的部分,其分界处便是碰撞点。
再举一例,图中线段仍是从铁块顶面出发,沿一个斜向上的方向擦过箱子表面,最终到达终点。过程中,因为微量伸长后的线段经过了铁块所占据的方块网格,所以铁块会被检查,但由于只有起点经过铁块表面,铁块并不会阻挡有向线段;尽管线段擦过箱子也是南面,但线段明显经过了其所在网格,所以箱子阻挡了线段。图中标注意义同上。
接着来一个相对离谱的,假如一个实体的 Y 轴 Motion 为 1000m / gt,水平 Motion 为 0,原位置在一个完整方块的中心,那么在碰撞检查中路径上距起点最近的一千等分点在完整方块上方 0.5m 处,不在碰撞箱内。接着,由于 Y 轴上 Motion 沿正方向,碰撞箱上垂直 Y 轴的面中只有下面会被检查(为了排除穿出碰撞箱的情况),而这一面没有被路径穿过。最终,实体不会被阻挡。
相对于上面说明的,由这一特性造成的一个更明显的问题就是以这种方式移动的实体有时不会被墙、栅栏、栅栏门、打开的潜影盒、short 属性为 false 的活塞头和 36 号方块超出其所在方块网格的部分(如栅栏的最上方 0.5 格)所阻挡,除非线段也经过了它们所在的方块网格,这一差别理论上也应该可以应用在弹射物矫正中。
在 1.16.1 及之前部分弹射物(如投掷物)检查的是方块的 Outline Shape(即光标对准时显示的黑框)的碰撞。这会使一些碰撞错误地发生(MC-73884 [21]),如撞到打开的栅栏门或草;也会使一些碰撞被错误地忽略,如 36 号方块的碰撞。
在基于 raycast 的弹射物运动过程中,游戏首先确定一个以原坐标为起点,以原坐标与位移趋势确定的目标位置为终点的有向线段并用它作为路径对方块进行 raycast。如果发生碰撞,则将路径截短到碰撞点。然后,游戏会尝试获取路径附近的所有collides()方法返回 true 的实体(例外:区域效果云、闪电束、物品、末影之眼、末影龙的较大轮廓、烈焰人火球、末影龙火球、凋零之首、幻魔者尖牙、经验球、箭矢、投掷物、鱼竿浮漂、带有 Marker 标签的盔甲架、烟花火箭和羊驼唾沫)的碰撞箱向六个方向各扩大 0.3 米后的立方体,并对这些长方体进行另一套 raycast,与对方块分割出的单个轴对齐碰撞箱的检查方式一致,即通常只有从长方体外部穿入的路径会被阻挡那个,而只在长方体内部的路径和从长方体内部穿出(而非穿入)的路径不会被阻挡。
如果多个实体碰撞箱拥有公共表面且碰撞点就在这一公共表面,理论上最近一次加载到该维度最早的实体会被碰撞。
因为获取待测实体列表时会先筛选出准备从中取出实体的区段(Subchunk),且只有与以移动路径为体对角线且底面平行坐标平面的一个长方体沿各方向扩大的区段会被选中,所以如果实体足够大(高度或宽度的一半大于),就会有一部分区域的碰撞被错误地忽略。但是在生存模式中几乎不需要考虑该问题,因为只有恶魂的碰撞箱大小达到了标准且可以与弹射物发生碰撞,而且只有在位置恰到好处,也就是高度接近坐标所在区段的上界时才会造成很少一部分碰撞的丢失。
总的来说,可以这样理解,一次移动中方块碰撞是检查该实体坐标点的直线 (而非 Entity.move() 中沿轴的折线) 运动轨迹上有没有方块阻挡,若有,在碰撞点处截短轨迹;实体碰撞则是检查一个以坐标为中心 0.6 * 0.6 * 0.6 的立方体在轨迹上是否发生了碰撞,若有,在碰撞点处截短轨迹。
如果多次移动的轨迹共线且期间路径附近的方块和实体没有发生改动,这几次移动与其合并后的一次移动大致是等价的。
需要注意,有些实体即使collides()方法返回 true 也不能与该弹射物发生碰撞,如投掷物,羊驼唾沫,烟花火箭还要求以下条件(ProjectileEntity. method_26958):
(1) 离开发射者,即第一次碰撞箱不与发射者接触且以碰撞箱附近没有与发射者直接或间接地骑乘同一个实体的实体的碰撞箱之后,详见ProjectileEntity.method_26961()。
(2) 目标实体不是旁观者,也没有死亡或被移除
(3) 目标实体与发射者不共用一个最底层骑乘实体
1、3 满足一个即可,2 必须满足。
也有一些类重写了该方法,在上述三类弹射物的基础上增加或是放宽了一些限制。PersistentProjectileEntity(箭矢三叉戟)的版本在原来的基础上使其不能在落地前第二次击中某一实体(想一下穿透附魔);火球凋零头和潜影贝导弹的版本在原来的基础上使 noClip 属性为 true 的实体 (如带有 NoGravity 标签的盔甲架) 被忽略;钓鱼竿浮漂则不仅是原条件情况下可与实体碰撞,物品实体即使collides()方法返回 false 也是可以的。
至于线与立方体的碰撞检查实现,可以这样说,游戏首先会检查该线与该立方体朝向起点的三个面所在平面的交点,然后检查该交点是否在该面的范围内(边上及与边上距离可以忽略的也算),如果是,那么这个点是有效点。最后游戏会在有效点中选取离起点最近的作为碰撞点,若没有,则判定没有碰撞(Box.java:244)。
同样,这里的方块碰撞检查也是忽略流体的,结果同上。另外,我们还发现这里没有对世界边境进行检查,这就可以解释投珍珠穿越边境的操作了。
要知道,上面研究的方法主要是弹射物的共有部分,即碰撞判定,完整的移动部分是各种实体各自实现的,还需要结合其运算流程研究,详见 7.7-7.9。
3 两种移动过程的对比
图 4.3 两种移动在碰撞检查角度下的路径点曲线
个人认为,两种移动方式有以下几点主要差别:
(1) Entity.move() 方法在碰撞判定中检查的是实体碰撞箱的碰撞情况,实体中只检查固体实体;基于 raycast 的弹射物自主移动对方块是检查的坐标点的碰撞情况,对符合条件的实体是检查的一个另类的 "碰撞箱" 的碰撞情况。
(2) Entity.move() 方法在碰撞判定中检查的是实体按沿轴折线移动碰撞情况,而基于 raycast 的弹射物自主移动对方块是检查的是直线轨迹上的碰撞情况。
(3) Entity.move() 方法会导致 X、Z 轴中发生碰撞的轴上 Motion 的归零,Y 轴上发生碰撞时通常也会导致其 Motion 分量的归零,但基于 raycast 的默认并不会,具体处理取决于实体类型。
(4) Entity.move() 方法会正确地处理碰撞箱超出其所在方块网格的方块,如墙和栅栏,但基于 raycast 的有时并不能检查到超出部分的碰撞。
(5) Entity.move() 方法会触发下方方块的一些效果,如红石矿石发光,但基于 raycast 的默认并不会。
(6) Entity.move() 方法会使 onGround(着地)状态更新,但基于 raycast 的并不会。
(7) Entity.move() 方法中实体总会检查方块网格碰撞,但在 1.16.1 以前,一部分弹射物(已确认有投掷物,不包括使用 move () 方法移动的和箭矢)自主运动时并不会进行这些处理。
(8) Entity.move() 不能检查到未加载区块中的碰撞,除非某次移动绝对沿轴,但基于 raycast 的总是可以。
无论哪种移动过程都对 TPS 不敏感,所以单纯的服务端卡顿不会导致碰撞检测发生错误,所以除了高 PING 或开有外挂的玩家外,应该没有 "卡穿墙了" 这一说。实际上,即使在客户端取消掉玩家的碰撞判定,在玩家速度不够快时仍不能穿墙。
另外,碰撞检查过程中一般不会发生导致实体卡到墙里或错误地发生碰撞的浮点数误差,因为方块的碰撞箱边界坐标都在浮点数集内。即使产生了浮点数误差,在 1.13 之后碰撞判定中也有一定的容错机制,确切地说来容错极限是m,已经足够了。所以一般来说,碰撞检查应该还是比较靠谱的。
4 源码走读
4.1 Entity.move():统一的移动入口
1.20.1 Yarn 中 move() 是 MovementType 参数化的统一入口:
注意 movementMultiplier 的 "用完即弃" 设计:它在一次 move() 调用中被消费后立刻被置零,下一次移动时不会再被应用。这意味着蛛网和浆果丛的减速只在实体停留其中并触发方块网格碰撞检查(slowMovement())后才生效一次。
4.2 adjustMovementForCollisions:沿轴碰撞 + 步高
1.14 改动:原文提到 1.14 前固定顺序为 Y→X→Z,1.14 后改为 "大轴优先(但此处 Yarn 1.20.1 的实现是小轴优先)"。这体现了原文基于 1.16.4 的研究 ——1.16.4 中确实是大轴优先。1.20.1 的改动(小轴优先)可能影响了依赖精确碰撞顺序的装置(如珍珠矫正器)。
4.3 步高(stepHeight)的源码实现
算法思路:如果直接水平移动被阻挡,尝试 "向上提 stepHeight 高度 → 水平移动 → 回落到最终高度"。只有当实际水平位移大于不抬脚时的位移时,才采用抬脚路径。这正是玩家能自动走上半砖(高度 ≤ 0.6)但走不上完整方块(高度 > 0.6)的源码原因。
