手把手教你搓——割草游戏碰撞检测框架
本文仅提供对象管理和碰撞检测策略和思路,不讨论其他性能优化方案如ECS。这些算法都没有绝对的优劣,都有各自的优缺点,实际应用中要根据情况选择合适的算法才能起到最大的优化效果。作为一个割草游戏,主要的玩法就是,主要的逻辑就是发射子弹攻击敌人。那么最关键的逻辑就是如何判断子弹能攻击到敌人,即碰撞检测...
手把手教你搓——割草游戏碰撞检测框架
本文作者:Don里个冬,转载需注明原作者。
前言
本文是之前在工作中真实实践过的经验总结,在此做个记录。
声明:本文仅提供对象管理和碰撞检测策略和思路,不讨论其他性能优化方案如ECS。
探索
在当今的各种游戏中,物理效果是游戏必不可少的表现效果和玩法逻辑之一。其主要由游戏引擎的物理系统提供逻辑实现。
那么我们作为游戏开发者,游戏物理引擎系统帮我们做的事情有哪些?
-
仿真物理行为:这是游戏物理引擎最基本的功能。它可以模拟重力、碰撞、刚体运动等物理行为,让游戏中的对象表现得更加逼真。
-
碰撞检测:游戏物理引擎可以实时检测不同对象之间的碰撞,并作出相应反应。比如子弹打中目标,计算伤害等。
-
刚体动力学:可以计算刚体的运动状态,包括速度、加速度、转动等,模拟更加复杂的物理行为。
-
软体动力学:模拟非刚体物理行为,比如头发、树叶、布料的运动等。使其移动更加自然逼真。
-
破坏效果:可以模拟不同物体受到撞击时的破坏效果,比如玻璃破碎,墙体坍塌等。
-
物理效果渲染:将计算出的物理仿真结果可视化,呈现出来,比如烟雾、火焰、液体的渲染。
作为一个割草游戏,主要的玩法就是,主要的逻辑就是发射子弹攻击敌人。那么最关键的逻辑就是如何判断子弹能攻击到敌人,即碰撞检测。
现如今的游戏引擎都提供了非常便捷使用的物理系统,我们拿Cocos来举例。
我们可以使用Cocos提供的物理碰撞系统,即使用Collider来做子弹和敌人的碰撞判断。
的确可以实现怪物和子弹直接的碰撞检测。
但是有个非常严重的问题:当场景出现大量碰撞体的时候,很卡!
下面是2022.11.30号测试的数据记录,Cocos版本3.6.3,微信小游戏环境,测试机型:iPhoneX。只保留怪物的碰撞检测逻辑(既判定两个obj相撞),不做任何碰撞后的逻辑处理,测试结果如下:
300个怪物的FPS | 600个怪物的FPS | |
Box2D(BoxCollider)(有刚体系统) | 真机1FPS |
真机1FPS |
Builtin2D(BoxCollider)(无刚体系统) | 真机19-21FPS |
真机1-4FPS |
Builtin2D(CircleCollider)(无刚体系统) | 真机17-23FPS |
真机7-5FPS |
可以看到,在场景中出现大量怪物的情况下,帧率非常低,几乎没法流畅地体验游戏。
这可以说是游戏引擎物理系统的通病。虽然物理系统使用很方便快捷,模拟的物理效果也非常精准,但是它非常耗。游戏引擎物理系统完全hold不住大量的碰撞体计算。
那如何做优化呢?
既然引擎现成的碰撞检测不太行,那就得我们自己造轮子了。
轮子怎么造,且让我们一步一步分析。
碰撞检测
我们先引入一个场景:
如果现在屏幕中有1个敌人和1个子弹,敌人和子弹行动轨迹随机,已知敌人和子弹的大小、坐标,如何判断在某一刻子弹是否击中敌人呢?
换种形式表述,即如何判断子弹的形状和敌人的形状是否相交?
要计算相交,首先要知道两个物体的形状。
对于游戏开发来说,碰撞形状一般分为三种:
-
长方形(Box)
-
多边形(Polygon)
-
圆形(Circle)
那么我们如何计算这些形状是否相交呢?
这里我们分别来讨论一下:
长方形碰撞:
AABB(Axis Aligned Bounding Box)和OBB(Oriented Bounding Box)
AABB碰撞盒使用与坐标轴平行的外接矩形来分析两个物体之间的碰撞关系,只需要比较两个矩形的上下、左右边的大小即可,计算量小。但是忽略了物体的旋转,这样会导致碰撞区域不太准确,适合进行粗略的碰撞检测。
与AABB碰撞盒相比OBB碰撞盒将旋转考虑进来,碰撞区域更加精准,适合精细的碰撞检测,但是也因此涉及到大量的三角函数运算,增加了计算复杂度。
多边形碰撞:
SAT(Separating Axis Theorem 分离轴定理)
如果两个物体不发生碰撞,则一定存在一条直线,能将两个物体分离。这条能够隔开两个物体的线称为分离轴。
可以假想有一束平行光从不同的角度照射待检测的两个多边形,当有一个合适的角度使得两者的影子没有重叠,那么分离轴就找到了。
对于计算来说,我们只需要依次在多边形的每个边做投影即可。
本质上OBB碰撞盒就是这个算法的四边形情况。
圆形碰撞:
计算外接圆
多边形外接圆圆心距离与两者的半径和作比较即可。
通过上面的三种计算方式对比可以看出,圆形和圆形判断相交是计算量最小的,只需要做一次 (R1+R2)^2 >= (X1-X2)^2 + (Y1-Y2)^2 的运算即可。
对于性能吃紧的微信小游戏来说,圆形的碰撞就已经足够用了。于是上面问题的答案可以简化为:
// 是否发生碰撞
public checkCollision(obj1, obj2): boolean
{
return (obj1.r + obj2.r) * (obj1.r + obj2.r) >= (obj1.x - obj2.x) * (obj1.x - obj2.x) + (obj1.y - obj2.y) * (obj1.y - obj2.y);
}
场景管理(空间加速技术)
现在我们再进一步,如果现在屏幕中有10个敌人,和1个子弹在,如何判断哪些敌人被子弹击中了呢?
那让子弹对每个敌人做一次碰撞检测不就完事了吗?
最简单最容易想到的做法就是用一个数组缓存起来然后遍历:
// 数组遍历
for(let i = 0; i < enemys.length; i++)
{
checkCollision(bullet, enemys[i]); // 碰撞检测
}
ok,这个没问题。
那我们再进一步,如果现在屏幕中有300个敌人和300个子弹,如何判断哪些敌人被子弹击中了呢?
还是让每个子弹对每个敌人做一次碰撞检测吗?
如果仍然使用数组存储,并且遍历,那么开销会非常大:
// 数组遍历
for(let i = 0; i < bullets.length; i++)
{
for(let j = 0; j < enemys.length; j++)
{
checkCollision(bullets[i], enemys[j]); // 碰撞检测
}
}
如何减少遍历的次数呢?
试想一下,对于屏幕边上的子弹,可以明显看出不可能与中间的敌人发生碰撞。
如果我们能提前知道这个信息,那么屏幕边上的子弹遍历的时候是不是就可以不与中间的敌人做计算了?
这也就意味着我们需要预处理一下这些对象的信息,就空间上来说,我们可以做区域划分。
网格:
划分之后,我们只需要关注每一个小格子。
比如对于小格子A,在小格子A里面的敌人和子弹才可能发生碰撞,在小格子A外面的敌人和子弹对于小格子A来说完全不需要考虑了。
最终的遍历开销会小很多:
// 网格遍历
for(let x = 0; x < xNum; x++)
{
for(let y = 0; y < yNum; y++)
{
checkObjs(bulletGrids[x][y], enemyGrids[x][y]);
}
}
// 数组遍历
public checkObjs(bullets, enemys)
{
for(let i = 0; i < bullets.length; i++)
{
for(let j = 0; j < enemys.length; j++)
{
checkCollision(bullets[i], enemys[j]);
}
}
}
下面是2022.11.30号测试的数据记录,Cocos版本3.6.3,微信小游戏环境,测试机型:iPhoneX。只保留怪物的碰撞检测逻辑(既判定两个obj相撞),不做任何碰撞后的逻辑处理,测试结果如下:
300个怪物的FPS | 600个怪物的FPS | |
Box2D(BoxCollider)(有刚体系统) | 真机1FPS |
真机1FPS |
Builtin2D(BoxCollider)(无刚体系统) | 真机19-21FPS |
真机1-4FPS |
Builtin2D(CircleCollider)(无刚体系统) | 真机17-23FPS |
真机7-5FPS |
网格 | 真机17-21FPS |
真机11-14FPS |
四叉树:
网格的格子是固定位置的,因此会存在很多冗余的格子,也会存在很多分布极不均匀的格子,并不能很精准地找到一个对象的近邻对象们。
那么如何做到更精准呢?
用四叉树。
即维护一个四叉树的数据结构,各个对象都均匀地分布在叶节点中。当一个叶节点达到可容纳的上限时,则从该叶节点分支出来新的四个叶节点,并把原来在这个叶节点上存储的对象分布到新的四个叶节点中。
需要查找邻近对象时,可以迅速地通过树形查找来确认较为精准的位置, 从而获得精准的邻近对象。
四叉树相比网格来说,会更加难以维护,因为四叉树的更新,需要更新枝叶节点。
而且在场景对象分布绝对集中的情况下,四叉树跟网格的查找开销没有太大区别。
下面是2022.11.30号测试的数据记录,Cocos版本3.6.3,微信小游戏环境,测试机型:iPhoneX。只保留怪物的碰撞检测逻辑(既判定两个obj相撞),不做任何碰撞后的逻辑处理,测试结果如下:
300个怪物的FPS | 600个怪物的FPS | |
Box2D(BoxCollider)(有刚体系统) | 真机1FPS |
真机1FPS |
Builtin2D(BoxCollider)(无刚体系统) | 真机19-21FPS |
真机1-4FPS |
Builtin2D(CircleCollider)(无刚体系统) | 真机17-23FPS |
真机7-5FPS |
网格 | 真机17-21FPS |
真机11-14FPS |
四叉树 | 真机33-34FPS |
真机15-17FPS |
SAP:
SAP算法全称Sweep And Prune,又称扫掠算法。是现代游戏引擎比较常用的碰撞检测算法(UE4使用的就是这个)。它将所有物体分别在坐标轴上做投影,如果在某一轴上不满足max1 > min2 && max2 > min1则不会发生碰撞。
什么叫做扫掠呢?
简单来说,就是所有的对象从上到下排好,像个印刷机一样,从头开始,遍历一条,然后向下移动,再遍历一条。直到移动到尾结束。
那它为什么比别的算法优呢?
接下来看了它的实现步骤之后,就能明白了。
具体步骤如下:
1、排序:
首先我们要知道,SAP也算是一种空间加速技术,所以说也需要维护一个具有空间优势的结构。这个结构只需要满足有序,用列表存储即可。
排序规则按照对象的x、y、z任意一轴的坐标大小即可。至于具体用哪个坐标轴,我们需要根据具体情况来分析,这里我们等下再讨论。先假设按照x轴坐标进行排序。
2、扫掠:
从左到右,对每个对象进行遍历,按照规则,如果在某一轴上不满足max1 > min2 && max2 > min1则不会发生碰撞。
先是A,按之前排序的顺序往下检测:
-
检测B,发现AB在x轴上满足最大点大于最小点的条件,则说明AB可能发生碰撞。
-
继续检测C,发现AC也在x轴上满足最大点大于最小点的条件,则说明AC也可能发生碰撞。
-
继续检测D,发现A的最大点小于了D的最小点,说明AD之间不能发生碰撞,D之后的对象也不可能与A发生碰撞,所以D之后的对象不再检测。
然后回到外层循环,再同理对B、C、D分别处理。
3、精准碰撞检测:
从x轴遍历出来所有可能发生碰撞的对象,只是做了粗筛。
还不能完全确定是否存在真正的碰撞。
此时有多种做法,根据项目的需求和使用场景,我们可以按需选择:
- 理论做法是还需要再从y、z轴上进行一遍上述操作,把所有可能发生碰撞的交集求出来,即真正发生碰撞的对象。
- 直接把x轴上可能发生碰撞的对象做一次做精准碰撞检测判断。
如果项目中精准检测碰撞的开销较小,比如使用圆形碰撞盒,则可以使用方案2,这样开销会更小。
// SAP遍历
for(let i = 0; i < objs.length; i++)
{
for(let j = i; j < objs.length; j++)
{
if(objs[i].xMax < objs[j].xMin || objs[i].xMin > objs[j].xMax)
{
break; // 不可能发生碰撞,后续无需检测了
}
checkCollision(objs[i], objs[j]); // 精准碰撞检测
}
}
问题1:
有些人可能会有疑问:内外两循环看上去仍是O(n^2)的复杂度,哪里省了呢?
因为我们有了第一步的排序,可以快速去除掉不满足条件的后面所有碰撞体,所以在有序遍历的情况下平均复杂度是O(nlogn)。
对于结构体维护更新来说,只有首次乱序的情况下,需要通过快速排序来减少排序开销。后续每帧场景上出现的小位移,最多只是几个对象的顺序发生改变,在基本有序的情况下我们用插入排序来重新维护结构体。
问题2:
如果在扫掠x轴的时候,所有的对象基本上都挤在一起,x坐标都非常接近,那么也就意味着,每次外层遍历几乎都需要遍历所有内层对象,起不到粗略筛选的作用。算法起不到应有的效果,此时该怎么办?
此时,可以换一个轴来进行扫掠,具体如何判断用哪个轴扫掠我们可以采用启发式思想来看:
试想一下,一个轴上的投影面积越多/长,是不是就意味着这个轴上的对象坐标分散的可能性越大?
因此如果需要(需要考虑额外开销),我们可以在扫掠之前,先粗判断一下哪个轴上的对象坐标分散的可能性大,然后再用这个轴来进行扫掠。
MBP:
说完了SAP,有没有比SAP更好的算法呢?
MBP算法全称Multi Box Pruning,本质上就是SAP + 网格。如何做,看图一眼便知:
下面是2023.5.3号测试的数据记录,Cocos版本3.7.3,微信小游戏环境,测试机型:iPhoneX。大量刷怪体验1分钟,测试结果如下:
iPhoneX平均FPS:32.8 -> 42.4
优化前(四叉树):
优化后(MBP):
其他相关知识点
碰撞掩码
一种快速用于计算判断两个对象是否需要进行碰撞检测的二进制数。
松散四叉树
松散的含义为:物体如果落在边界上,那在各个节点内都存一份。
本质上是为了解决物体落在边界上碰撞判断问题的一种策略。
BVH树(Bounding Volume Hierarchy)
每个父节点都能最小包含子节点。
多用于场景光线追踪计算(曾经多用KD树,如今多用BVH树),而非游戏场景管理。
优:查树的时候,比四叉树更快。
劣:建树、更新树,比四叉树更慢。
BSP树
最早游戏引擎使用的树。后面随着硬件的提升,BSP树逐渐从游戏引擎中淘汰。
但BSP树仍然活跃在其他领域,比如室内视口剔除等。
KD树(K-Dimensional)
数据结构专题(一) | kd-tree 原理深入理解【看这一篇就够了】
可以看作是划分规矩的BSP树。
划分值的选择通常就是中值点法,选择某维坐标中位数作为划分值,完美体现二分法思想,能最有效的划分开数据。
对比KNN查找(K Nearest Neighbor)
优:高效的邻近查找。
劣:动态场景支持较差,因为一旦场景发生变动,KD树只能重建而不是更新。
隧道效应
速度过快穿过物体导致碰撞失效问题。
解决方案:
1、增加检测次数/频率。
2、连续碰撞检测 Continuous Collision Detection(CCD):
把刚体连续移动所覆盖的空间(扫掠体)当作新的形状作覆盖检测,如下图。
计算出该胶囊体与挡板的接触点后,可以直接将小球移动到该位置。
总结
这些算法都没有绝对的优劣,都有各自的优缺点,实际应用中要根据情况选择合适的算法才能起到最大的优化效果。
参考
UE4物理模块(三)—碰撞查询(下)SAP/MBP/BVH算法简介
空间划分算法优化碰撞检测研究(网格划分、四叉树\八叉树、层次包围盒树BVH)Unity C#实现_八叉树碰撞检测-CSDN博客
更多推荐
所有评论(0)