在2D空间中使用四叉树实现碰撞检测

原文链接:https://gamedevelopment.tutsplus.com/tutorials/quick-tip-use-quadtrees-to-detect-likely-collisions-in-2d-space--gamedev-374

许多游戏需要使用碰撞检测算法来决定两个物体什么时候发生碰撞,但是这些算法很消耗CPU,可能会大幅度降低游戏速度。所以在这篇文章中,我们就来学习有关四叉树的知识,以及我们如何使用它们来跳过一些因为太远不可能会碰到的物体,从而加快碰撞检测的速度。

注意:尽管这个教程是用Java来写的,但你可以使用同样的技术和概念在几乎任何游戏开发环境里。

引言:

碰撞检测是大部分视频游戏的关键部分。不管在2D还是3D游戏里,检测两个物体发生碰撞是非常重要的,一个小小的碰撞检测可以为游戏加分不少。

但是,碰撞检测一种花费巨大的操作。比如说,现在有一百个物体需要检测是否发生了碰撞,两两物体比较后需要执行操作10000次——这样的数量太惊人了!

有一种方法可以加快过程,即减少检测数量。两个物体在屏幕相反的两边是绝对不会发生碰撞的,所以没必要检测他们之间的碰撞。从这里就要进入四叉树了。

什么是四叉树?

四叉树是一种数据结构,被用来将一个2D区域分为更多可管理的范围。它是二叉树的扩展,但是不像二叉树每个节点有两个孩子,它有四个孩子。

在下面的图片中,每个图片代表2D区域的可视范围,红色的正方形代表物体。为了更好的表述这篇文章,子节点的顺序会被标示成如下图所示的顺时针方向。


四叉树起始于单节点。对象会被添加到四叉树的单节点上。

当更多的对象被添加到四叉树里时,它们最终会被分为四个子节点。(我是这么理解的:下面的图片不是分为四个区域吗,每个区域就是一个孩子或子节点)然后每个物体根据他在2D空间的位置而被放入这些子节点中的一个里。任何不能正好在一个节点区域内的物体会被放在父节点。


如果有更多的对象被添加进来,那么每个子节点要继续划分(成四个节点)。


正如你看到的,每个节点仅包括几个物体。这样我们就可以明白前面所说的规则,例如,左上角节点里的物体是不可能和右下角节点里的物体碰撞的。所以我们也就没必要运行消耗很多资源的碰撞检测算法来检验他们之间是否会发生碰撞。
点击这里查看一个JavaScript写的例子来立即了解四叉树。

使用四叉树

使用四叉树是非常简单的。下面的代码使用Java写的,但是同样的技术可以用很多其他编程语言来写。我会在每个代码片段后面注释。
首先我们开始创建一个重要的四叉树的类,下面的代码就是Quadtree.java。

public class Quadtree {

  private int MAX_OBJECTS = 10;
  private int MAX_LEVELS = 5;

  private int level;
  private List objects;
  private Rectangle bounds;
  private Quadtree[] nodes;

/*
  * Constructor
  */
  public Quadtree(int pLevel, Rectangle pBounds) {
   level = pLevel;
   objects = new ArrayList();
   bounds = pBounds;
   nodes = new Quadtree[4];
  }
}

这个Quadtree类很直观。MAX_OBJECTS变量表示在节点分裂前一个节点最多可以存储多少个孩子,MAX_LEVELS定义了四叉树的深度。Level变量指的是当前节点(0就表示是每四个节点的父节点),bounds代表一个节点的2D空间的面积,nodes变量存储四个子节点。
在这个例子里,四叉树每个节点的面积都定义成正方形的,当然你的四叉树节点的面积空间可以为任意形状。然后,我们会使用五个四叉树里会用到的方法,分别为:clear,split,getIndex,insert和retrieve。

/*
* Clears the quadtree
*/
public void clear() {
   objects.clear();

   for (int i = 0; i < nodes.length; i++) {
     if (nodes[i] != null) {
       nodes[i].clear();
       nodes[i] = null;
     }
   }
}

Clear函数,是通过递归来清除四叉树所有节点的所有对象。

/*
* Splits the node into 4 subnodes
*/
private void split() {
   int subWidth = (int)(bounds.getWidth() / 2);
   int subHeight = (int)(bounds.getHeight() / 2);
   int x = (int)bounds.getX();
   int y = (int)bounds.getY();

   nodes[0] = new Quadtree(level+1, new Rectangle(x + subWidth, y, subWidth, subHeight));
   nodes[1] = new Quadtree(level+1, new Rectangle(x, y, subWidth, subHeight));
   nodes[2] = new Quadtree(level+1, new Rectangle(x, y + subHeight, subWidth, subHeight));
   nodes[3] = new Quadtree(level+1, new Rectangle(x + subWidth, y + subHeight, subWidth, subHeight));
}

Split方法,就是用来将节点分成相等的四份面积,并用新的边界来初始化四个新的子节点。

/*
* Determine which node the object belongs to. -1 means
* object cannot completely fit within a child node and is part
* of the parent node
*/
private int getIndex(Rectangle pRect) {
   int index = -1;
   double verticalMidpoint = bounds.getX() + (bounds.getWidth() / 2);
   double horizontalMidpoint = bounds.getY() + (bounds.getHeight() / 2);

   // Object can completely fit within the top quadrants
   boolean topQuadrant = (pRect.getY() < horizontalMidpoint && pRect.getY() + pRect.getHeight() < horizontalMidpoint);
   // Object can completely fit within the bottom quadrants
   boolean bottomQuadrant = (pRect.getY() > horizontalMidpoint);

   // Object can completely fit within the left quadrants
   if (pRect.getX() < verticalMidpoint && pRect.getX() + pRect.getWidth() < verticalMidpoint) {
      if (topQuadrant) {
        index = 1;
      }
      else if (bottomQuadrant) {
        index = 2;
      }
    }
    // Object can completely fit within the right quadrants
    else if (pRect.getX() > verticalMidpoint) {
     if (topQuadrant) {
       index = 0;
     }
     else if (bottomQuadrant) {
       index = 3;
     }
   }

   return index;
}

getIndex方法是个四叉树的辅助方法,在四叉树里,他决定了一个节点的归属,通过检查节点属于哪个象限。(最上面第一幅图不是顺时针在一个面积里划分了四块面积,上面标示了他们的序号,这个方法就是算在一个父节点里他的子节点的序号)

/*
* Insert the object into the quadtree. If the node
* exceeds the capacity, it will split and add all
* objects to their corresponding nodes.
*/
public void insert(Rectangle pRect) {
   if (nodes[0] != null) {
     int index = getIndex(pRect);

     if (index != -1) {
       nodes[index].insert(pRect);

       return;
     }
   }

   objects.add(pRect);

   if (objects.size() > MAX_OBJECTS && level < MAX_LEVELS) {
     split();

     int i = 0;
     while (i < objects.size()) {
       int index = getIndex(objects.get(i));
       if (index != -1) {
         nodes[index].insert(objects.remove(i));
       }
       else {
         i++;
       }
     }
   }
}

Insert方法,是将节点聚合在一起的方法。方法首先判断是否有父节点,然后将这个子节点插入父节点的某一序号的孩子上。如果没有子节点,或者这个节点的所属面积不属于任何一个子节点的所属面积,那就将它加入父节点。
一旦对象添加上后,要看看这个节点会不会分裂,可以通过检查对象被加入节点后有没有超过一个节点最大容纳对象的数量。分裂起源于节点可以插入任何对象,这个对象只要符合子节点都可以被加入。否则就加入到父节点。

/*
* Return all objects that could collide with the given object
*/
public List retrieve(List returnObjects, Rectangle pRect) {
   int index = getIndex(pRect);
   if (index != -1 && nodes[0] != null) {
     nodes[index].retrieve(returnObjects, pRect);
   }

   returnObjects.addAll(objects);

   return returnObjects;
}

最后一个四叉树的方法就是retrieve方法,他返回了与指定节点可能发生碰撞的所有节点。这个方法成倍的减少碰撞检测数量。

用这个类来进行2D碰撞检测

现在我们有了完整功能的四叉树,是时候使用它来帮助我们减少碰撞检测了。
在一个特定的游戏里,开始创建四叉树,并将屏幕尺寸作为参数传入(Rectangle的构造函数)。

Quadtree quad = new Quadtree(0, new Rectangle(0,0,600,600));

在每一帧里,我们都先清除四叉树再用inset方法将对象插入其中。

quad.clear();
for (int i = 0; i < allObjects.size(); i++) {
  quad.insert(allObjects.get(i));
}

所有的对象都插入后,就可以遍历每个对象,得到一个可能会发生碰撞对象的list,然后你就可以在list里的每一个对象间用任何一种碰撞检测的算法检查碰撞,和初始化对象。

List returnObjects = new ArrayList();
for (int i = 0; i < allObjects.size(); i++) {
  returnObjects.clear();
  quad.retrieve(returnObjects, objects.get(i));

  for (int x = 0; x < returnObjects.size(); x++) {
    // Run collision detection algorithm between objects
  }
}

注意:碰撞检测算法超出了这个教程的范围,查看碰撞检测算法

总结:

碰撞检测是一种非常耗CPU的操作,可能会降低你的游戏性能。而四叉树就是一种可以加快碰撞检测速度的方法,它可以让你的游戏高效运行。

推荐阅读更多精彩内容