【物理篇】从零搭建2D物理系统②——用松散四叉树结合网格法来划分场景

从一道字节跳动面试题说起

在开始今天内容之前,我想先讲一道前几天看到的字节跳动面试题:

玩家在场景中放了一个AOE技能,场景中有10万个敌人,如何知道AOE技能打到哪些敌人?

这道题的解法肯定不可能是挨个遍历,那样复杂度会爆炸。这道题想考察的是空间划分的知识。空间划分指的是使用特定的数据结构来划分场景空间中的物体,从而在碰撞检测的时候能够快速过滤掉不需要的部分(如果只想知道问题回答请跳过文章内容,直接拉到最下面。)

比如下图中使用了四叉树的数据结构划分场景,绿球要想知道它和哪些红球进行相撞,它只需要进行三次过滤就能把范围缩小到K区域,然后与K区域内的红球进行挨个碰撞检测就能得出碰撞结果。
在这里插入图片描述
当然空间划分除了做碰撞检测,还能用于相机剔除算法、寻找最近点、光线追踪等等用途。
《空间划分的数据结构(四叉树/八叉树/BVH树/BSP树/k-d树)》,这篇文章详细介绍了经典的几种空间划分数据结构,这里我就不一一展开。在碰撞检测用途上,常用的有四叉树、八叉树、网格、BVH这几种数据结构,比较如下:

  • 四叉树和网格法适合2D,网格法增删查最快,但是占用内存大
  • 八叉树和BVH适合3D,使用优化后的八叉树插入效率会比BVH快,但是查找效率比BVH慢
  • 上面这几种都能支持动态更新,效率上:网格法>四叉树\八叉树> BVH

网格法

上面提到的文章里有一种数据结构没有提到,就是网格。网格,顾名思义就是把游戏场景划分为一个个格子,如图所示,2D的情况下使用的是一个二维数组,图中的白块处于第6行第6列(左下作为起点),那么它应该存入[5,5]的索引中。
在这里插入图片描述
如果物体跟网格线交叉了,如下图,物体没有完全处于网格内,那么它应该存储在和它交叉的所有网格:[4,4],[4,5],[5,4],[5,5]。
在这里插入图片描述
当查询时,只要找到物体所在的网格,拿出网格内的所有其他物体挨个遍历即可。
到这里,我们可以总结出网格法的优缺点

  • 物体插入时很快,只有O(1)复杂度
  • 物体可以很方便地动态更新自己在网格中的位置
  • 对于跨多个网格的物体,需要占用更多的空间来存储

四叉树

接着来看四叉树,四叉树对于处在分割线上的物体(为了方便称呼,叫“压线”好了),是存储在和它交叉的最上层级的节点上。比如处在世界中心位置的物体,就会存储在根节点。

而在查询过程中,除了需要跟物体所在节点的其他物体进行遍历,还需要与所在节点的所有父节点上存储的物体进行遍历,因为这些父节点上存储了压线的物体,有可能与当前做查询的物体进行碰撞。

如图,绿球除了和K区域的红球碰撞检测外还需要与蓝球进行检测,这些蓝球都是存储在父节点中。而存储在根节点的蓝球,每次检测都要带上它,其实它跟绝大部分物体都不碰撞,这样就会浪费时间。
在这里插入图片描述
对于压线的物体,如果不存储在父节点上,而是采用和网格法相同的思路,存储在和它交叉的所有子节点上,则会占用很多空间。

在父节点上存储压线的物体,虽然节省了内存,但是会导致无用的检测增加,这是四叉树的优点也是缺点之一。

四叉树的另一个问题在于动态更新
想想看,如果四叉树内的物体每帧位置都在变化,四叉树应该如何更新?每帧都重建四叉树?那肯定不行,太费时间了。 首先需要找对旧位置所在的节点 ,把节点里存的物体列表中删掉当前这个在移动的物体,删掉后还要考虑已经分裂的子四叉树是不是需要删除,然后对于新位置的处理,这个就比较好办,按照插入时的逻辑就可以了。

还有一个问题是,如果物体在边界线反复移动,那么它所在的位置就会不停的变化,这样就需要不停地插入四叉树


松散四叉树

为了解决物体在边界线反复移动不得不更新位置的问题,松散四叉树的数据结构被提了出来。
在这里插入图片描述
在非松散的四叉树/八叉树中,入口边界和出口边界是一样的。
而松散四叉树/八叉树中,是指出口边界比入口边界要宽些(虽然各节点的出口边界也会发生部分重叠),从而使节点不容易越过出口边界,减少了物体切换节点的次数

问题在于如何定义出口边界的长度。因为太短会退化成正常四叉树/八叉树,太长又可能会导致节点存储冗余的物体。经过前人的实验表明出口边界长度为入口边界2倍最佳

出口范围是入口范围2倍的时候,松散四叉树会多一个神奇的特性:
如果一个AABB大小小于一个四叉树节点的入口范围,那么只要这个AABB的中心点在入口范围内,那么这个AABB一定包含在出口范围内。


松散四叉树结合网格法

前戏做了这么多,终于到今天的主角了,松散四叉树结合网格法。我们都知道,当四叉树节点里的物体数量到达一个阈值时,我们会分裂四叉树,如果要支持动态更新,分裂后的四叉树可能还要删除。那么如果我们一上来就把四叉树全部分裂完呢?不管节点里有没有物体都不删除四叉树节点呢?这样的话这个四叉树就会跟网格很像。

有了松散四叉树配合网格法,我们能够快速找到一个物体应该插入的位置。
在这里插入图片描述
如图,我们把一个区域分成了三层的网格,图中黄色线代表第一层的网格,绿色线代表第二层的网格,白色线代表第三层的网格。我们可以发现,蓝色方块的大小大于第三层的网格(只有宽、高其中之一比网格大就算),小于第二层的网格,所以它最终应该放在第二层的四叉树上

接着我们来看它的中心点的位置,我们使用的是2倍的松散四叉树,所以中心点在哪个节点,物体就在哪个节点。通过观察我们可以发现,蓝色方块的位置在第一层四叉树的左下节点,第二层四叉树的左上节点

我们用一个2x2的2维数组来表示四叉树的四个子节点,用行和列表示索引,左下是[0,0],左上[1,0],右下是[0,1],右上是[1,1]。蓝色方块的位置就是根节点的[0,0]子节点的[1,0]子节点

发现没有,使用松散四叉树结合网格法,可以快速得出一个物体要插入的位置,从原来的O(logn)复杂度变成O(1)复杂度。(可能有人会疑惑为何原先是logn复杂度,因为传统四叉树的插入是一种二分查找算法。二分查找的复杂度就是logn。二分查找是两半取其一不断循环,四叉树插入是四半取其一不断循环,本质一样。)


代码实现

刚才分析的案例使用的是肉眼观察,接下来我会讲代码如何推算结果。我们举一个更复杂的例子:
在这里插入图片描述
图中蓝色方块的位置是:第一层存放在右下,所以是[0,1];第二层存放在右上,所以是[1,1];第三层存放在左上,所以是[1,0]。

首先我们先根据物体的大小判断出它最终落在哪一层,这一步很简单,因为每一层网格的大小是固定的,事先把网格大小存起来然后挨个比较即可:

public int GetDepth( Vector2 size )
{
	for( int i = _gridSizes.Length - 1; i >= 0; i-- )
	{
		if( size.x <= _gridSizes[i].x && size.y <= _gridSizes[i].y )
		{
			return i;
		}
	}
	Debug.LogError( "Size is bigger than QuadTree Max Range" );
	return -1;
}

图中案例,它的层数是3。 然后我们算出它在这一层的网格中处于哪一行哪一列。图中案例的物体处于第3行第6列(从0开始算)

int row = Mathf.FloorToInt( ( center.y - _worldRect.yMin ) / gridsize.y );
int column = Mathf.FloorToInt( ( center.x - _worldRect.xMin ) / gridsize.x );

然后我们把行数和列数除以2的层数减一次幂。,也就是3/4,6/4,会得到商0和1。[0,1]正好就是第一层的位置。

然后把3/4,6/4的余数3和2进行除以2的层数减2次幂,也就是3/2,2/2,会得到商1和1。[1,1],是不是正好就是第二层的位置。

然后就这把上一次计算的余数1和0,除以2的0次幂,也就是1/1,0/1,得到结果[1,0],正好就是第三层的位置。
代码如下:

var depth = GetDepth( size );
if( depth == 0 )
{
	//深度为0,直接存在根节点
	posInfo.inRoot = true;
	return;
}

var gridsize = _gridSizes[depth];
int row = Mathf.FloorToInt( ( center.y - _worldRect.yMin ) / gridsize.y );
int column = Mathf.FloorToInt( ( center.x - _worldRect.xMin ) / gridsize.x );

int tempRow = row;
int tempColumn = column;

var storeDepth = 0;
//posInfo是一个用来存储位置的数据结构
posInfo.storeDepth = depth;
for( int i = depth - 1; i >= 0; i-- )
{
	int div = (int)Mathf.Pow( 2, i );
	int rowIndex = tempRow / div;
	int columnIndex = tempColumn / div;
	
	tempRow %= div;
	tempColumn %= div;
	
	posInfo.posInDepths[storeDepth].rowIndex = rowIndex;
	posInfo.posInDepths[storeDepth].columnIndex = columnIndex;
	storeDepth++;
}

这就是本节全部内容,github工程
对应的场景在Test/Scenes/QuadTreeGenerator。运行时能在Console看到物体的位置信息:
在这里插入图片描述

对了,我们回到一开始的面试题。既然都提到了问题我还是列一下我的回答,仅供参考:

对于10万个敌人这种情况,得使用合理的数据结构对场景进行划分。如果是2D的话,可以使用网格法和四叉树,网格的插入删除查询都比较快,但是它对于跨多个网格的物体需要占用很多空间来存储。四叉树虽然效率上不如网格,但是通过使用松散四叉树可以良好地提升效率。如果是3D的话,可以使用八叉树和BVH。BVH的查询是比八叉树快,但是在考虑到动态更新的情况下八叉树更好,BVH需要更新当前物体所在节点的整个分支,而八叉树只需要更新旧位置和新位置的两个节点。(BVH的动态更新参考Game Physics: Broadphase – Dynamic AABB Tree

答题思路是首先指出这是一个空间划分问题,关键在于选择合理的数据结构。然后你需要分析题目中的情况,适合什么样的数据结构,并且比较这些数据结构的优缺点。对于你会的,可以随便说,不会的不说。比如上面的回答中,我故意避开复杂度的问题。因为我不是很有把握。

网格的单个物体插入、查询都是O(1)复杂度,四叉树的插入是O(logn),查询最坏情况下是O(n)。BVH插入的O(logn),查询是O(logn)。八叉树和四叉树一样。这些结论有些是我个人推导的,不是很有把握正确。如果面试官不问我肯定不说。


关于作者:

  • 水曜日鸡,简称水鸡,ACG宅。曾参与索尼中国之星项目研发,具有2D联网多人动作游戏开发经验。

CSDN博客:https://blog.csdn.net/j756915370
知乎专栏:https://zhuanlan.zhihu.com/c_1241442143220363264
交流学习群:891809847

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值