[游戏开发] 四叉树碰撞优化版,速度飞一样

Posted by Tsang Si on 2015-06-12

原来对四叉树有点恐惧,完全不知道这东西怎么回事,觉得很高级,只好花时间研究了一下,最终效果如下:

下面是对四叉树的基本介绍。
懂四叉树的同学可以直接跳过前面看优化部分。
四叉树很简单,就是把一块2d的区域,等分成4份,如下图: 我们把4块区域从右上象限开始编号, 逆时针。

接着每份继续等分4份。

一直到我们不需要再继续等分为止。
每次插入对象,对象都在其中一个格子里。 进行碰撞检测的时候,对象只会和他同在一个格子里的以及周围的一些对象进行检测,所以提高了效率。
四叉树的原理大致就是这样,和网格法差不多, 简单说就是分格子,检测。
//---------------------------------------------分割线-----------------------------------------------------------//
以下部分会对四叉树的实现以及优化进行说明。
下面我们对四叉树进行实现:
主要代码

package
{
        public class Quadtree
        {
                private var MAX_OBJECTS:int = 10;
                
                private var MAX_LEVELS:int = 5;
                
                private var level:int;
                private var objects:Array;
                private var bounds:Rectangle;
                private var nodes:Vector.<Quadtree>;
        }
}

MAX_OBJECTS是 每个节点能容纳的最多对象 超过 则分割4个节点, 我们并不是事先就分好格子, 而是在插入对象的时候才进行划分。

MAX_LEVELS: 四叉树的最大层数 超过 则不再划分 从根节点开始 最多6 层。

level: 当前层数

objects: 当前节点内的待检测的对象。

bounds: 当前节点所表示的2d区域的范围

nodes: 4个子节点队列。

构造函数:

public function Quadtree(level:int, bounds:Rectangle)
{
        this.level = level;
        this.bounds = bounds;
        objects = [];
        nodes = new Vector.<Quadtree>(4);
}

分割函数:

/**
 * 4象限如此划分 逆时针
 * 1 0 
 * 2 3
 */                
public function split():void
{
    var subWidth:int = bounds.width/2;
    var subHeight:int = bounds.height/2;
    var x:int = bounds.x;
    var y:int = bounds.y;
    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));
}

如你所见,每次划分节点,都会把父节点的区域对半分。

辅助方法 getIndex:

private function getIndex(rect:Rectangle):int
{
    var index:int = -1;
    var xMidPoint:Number = bounds.x + bounds.width/2;
    var yMidPoint:Number = bounds.y + bounds.height/2;
    
    var topQuadrant:Boolean = rect.y < yMidPoint && rect.y + rect.height < yMidPoint;//Quadrant 象限
    var bottomQuadrant:Boolean = rect.y > yMidPoint;
    if(rect.x < xMidPoint && rect.x + rect.width <xMidPoint)//在左象限
    {
        if(topQuadrant)
        {
			index = 1;
        }
        else
            index = 2;
    }
    else if(rect.x > xMidPoint)
    {
        if(topQuadrant)
        {
            index = 0;
        }
        else
            index = 3;
    }
    return index;
}

这个方法 是返回你所要碰撞检测的矩形 在哪块区域(当前节点)。
比如当前区域是Rectange(0, 0, 600, 600) 待检测矩形是Rectangel(0, 0, 30, 30) 那么他就在左上象限 index = 1 如果是Rectange(400, 400, 30, 30) 那么他就在右下象限 index = 3 请注意, 当对象刚好在 区域的中心线上的时候, 也会返回-1。

下面是插入方法insert
最关键的方法:

public function insert(rect:Rectangle):void
{
    if(nodes[0] != null)
    {
        var index:int = getIndex(rect);
        if(index != -1)
			nodes[index].insert(rect);
        return;
    }
    objects.push(rect);
    if(objects.length > MAX_OBJECTS && level < MAX_LEVELS)
    {
        split();
        var i:int=0;
        while(i < objects.length)
        {
            index = getIndex(rect);
            if(index != -1)
            {
                nodes[i].insert(objects.pop());
            }
            else
				i++;
        }
    }
}

每次插入一个对象 我们都先看看当前节点有没有子节点 如果有 我们就插入子节点。 一直检测到他没有子节点为止 我们就把对象插入到这个节点, 如果这个节点的对象数量 > 10个 并且当前节点的层数 < MAX_LEVELS 我们就把节点继续划分4个子节点。 然后把当前对象循环 删除 并插入子节点。如果对象在中心线上,getIndex会返回-1, 所以这些对象会被插入到父节点上面。

最后一个方法,他输入一个你要检测的对象,返回你需要检测的对象列表:

public function retrive(returnObjects:Array, rect:Rectangle):void
{
    var index:int = getIndex(rect);
    if(index != -1 && nodes[0] != null)
    {
        nodes[index].retrive(returnObjects, rect);
    }
    for each(var r:Rectangel in objects)
    {
        returnObjects.push(r);
    }
}

就是从根节点开始 一直递归找到对象所在的节点 然后把节点里的对象全push进去 再把它的父节点的对象push进去 一直到根节点为止。

进行碰撞检测的代码:

private var quad:Quadtree = new Quadtree(0, new Rectangle(0, 0, 600, 600));
private var sprList:Vector.<Shape>;
private var allObjects:Vector.<Rectangle>;

private function onEnterFrame(e:Event):void
{
    quad.clear();
    for(var i:int = 0; i<sprCnt; i++)
    {
        //--移动代码--//
        quad.insert(allObjects[i]);
    }
    for(i = 0; i<sprCnt; i++)
    {
        //碰撞检测代码
    }
}

四叉树的代码就到这里为止了。 这只是一个开始而已。

//-----------------------------------分割线---------------------------------------//
下面是四叉树的优化部分。
折腾半天,四叉树终于写好了。 运行, 一看 1000对象 居然只有10几20fps。
哥怒了!看人家写的1000对象也有30fps, 咋我写的效率就这低呢?
优化折腾了更半天, 硬是把帧数从10几fps 活活优化到 50fps。 我都不得不表扬一下我自己了!!
下面让我一条一条优化说明。

1.屏幕上移动的对象 是 Sprite , 我们可以把他们换成Shape来提高效率, 换成shape以后大约提高了 1 fps。

2.张明光大师曾经说过, 对于动态四叉树, 由于你每次都要重新遍历节点(意思应该是 你每个检测对象都要重新插入到四叉树里 因为每次对象移动他所处的节点都可能发生变化 看上面onEnterFrame 每次都重新insert), 它比普通的遍历对象还要慢。实际上也没有那么慢。

那么 我们要如何不让对象每次都要重新插入呢, 看下面:

每次对象所在的节点位置发生了变化 就会从红色变为绿色。
注意到 对象所处的节点并不是一直变化, 只是在接触到分割线的一瞬间 以及进入到另一个象限的一瞬间, 对象所处的节点会发生变化。
由getIndex 和 insert方法你可以知道, 当对象完全处于4个象限中 会返回0 1 2 3, 而当对象刚好移动到 分割线上时 它不属于任何一个象限, 所以会被insert到父节点上。
所以 我们并不需要每次都重新插入对象 只在对象所处的节点位置发生变化的时候, 重新插入。
引入一个新类:

package
{
    import flash.geom.Rectangle;

    public class Rect extends Rectangle
    {
        /** 这是记录移动前处于四叉树哪个节点*/                
        public var lastIndex:int;
        /** 记录移动后处于四叉树哪个节点*/                
        public var nowIndex:int;
        /** 他的父节点 记录父节点是为了在删除对象的时候方便操作 不用从根节点遍历*/                
        public var parent:QuadTree;
        public function Rect(x:Number, y:Number, width:Number, height:Number)
        {
            super(x, y, width, height);
            lastIndex = 0;
            nowIndex = 0;
        }
    }
}

新加两个属性 lastIndex 和 nowIndex来记录对象移动前后所在的节点。 parent为了方便删除对象。
观察后我们发现, 在retrive方法里 每次都有递归计算 对象的index 所以我们可以在这里保存一下对象的index。retrive方法修改如下:

public function retrive(returnObjects:Array, rect:Rectangle):void
{
    var index:int = getIndex(rect);
    if(index != -1 && nodes[0] != null)
    {
        rect.nowIndex = (rect.nowIndex << 2) + index;
        nodes[index].retrive(returnObjects, rect);
        return;
    }
    for each(var r:Rectangel in objects)
    {
        returnObjects.push(r);
    }
    if(rect.nowIndex != rect.lastIndex)
    {
        //把对象删除并重新插入
    }
    rect.lastIndex = rect.nowIndex;
}

因为getIndex每次都返回0-3 用2进制表示就是00 01 10 11 所以我们可以把每次的结果左移两位 << 这样可以得到如 11 01 00 之类的结果 达到保存对象节点位置的办法。

加了这部分代码后, 程序效率并没有如我想的那样 fps变成double, treble
fps大约增加了 5-10 帧 有20几帧的样子。
这。。 看人家每次都是动态插入 删除 也有30fps, 我这需要才插入居然还没有人家高。
吐了一口老血继续优化。

3.研究后发现, 在retrive方法里

for each(var r:Rectangel in objects)
{
    returnObjects.push(r);
}

push也是一个很花时间的东西, 1000对象 每个对象返回的待检测对象大约几个~上百个之间, 假如平均30个 那么我们每帧就要push 3万次。
而且由于retrive方法是个递归方法,更花时间。
研究后决定 每次直接push整个objects数组 检测碰撞时用二维数组检测, 并且利用rect的 parent属性 去掉递归方法。把retrive整理一下, 计算index的部分我们放到另外一个方法calIndex中。
所以retrive方法最后被优化成这个样子:

public function retrive(returnObjects:Array, rect:Rect):void
{
    var q:QuadTree = rect.parent;
    while(q)
    {
        returnObjects.push(q.objects);
        q = q.parent;
    }
}

计算时间立马变为原来的1/10.

4.给Rect加一个新属性Hitted

/** 如果已经碰撞了 就不用再检测了*/                
public var hitted:Boolean;

每次碰撞检测 如果A碰到B 那么B的hitted属性就设为true, 下次检测B的时候就跳过B. 加入1000对象基本所有的对象都有碰撞,等于只检测了一半物体, 效率立马提高100%。

5.到了这里 已经绞尽脑汁了

还有什么优化呢?
思考n久 发现,retrive方法里 会从对象所在节点开始 一直遍历到根节点 把节点上的对象全部push进待检测列表里。
但是, 我们很容易发现 每个检测对象 最多和自己所在节点, 父节点, 父节点的父节点有关系, 最多3层。 比如1000对象, 最左下角的格子 就不可能和 根节点的对象有交集。
下面是在 优化以前的retrive方法:

点击任意矩形返回它可能会碰撞检测的列表。
你发现 即使你选择了最左下角的矩形, 根节点 也就是 屏幕最中间的那两条线 矩形最多的那两条, 上面的矩形也会返回。

下面是retrive方法优化以后。

发现没有, 返回的节点数量立马少了一大半。
它只会返回2-3层节点。
对于处于屏幕左下象限的对象, 如果对象在矩形区域内的左下角区域, 他会返回当前小矩形内的对象, 以及他的父节点内的对象。 2层。
否则他会返回3层对象, 具体可以测试看看。
(这个其实不是跟左下象限有关, 而是 当前对象的父节点的所在象限 和 他的父节点的父节点的所在象限相同 则返回两层, 否者返回三层 所以最左上, 右上, 左下, 右下的 对象 都只返回2层节点)
(这个返回2-3层的优化 必须是 你的检测对象大小至少 不大于 最小的格子大小 否则会不准确 比如一个对象就占了n个格子。。。)

6.优化到这里, 脑细胞已经死完了, 效率也可以了,但是还有什么可以优化么?

是的, 的确还有。。
虽然优化过的retrive方法 返回的对象已经少了很多, 但是并不是所有返回的对象都需要进行碰撞检测。
比如对象处于左下角的矩形, 他就是在左象限 以及下象限(这个很好理解吧),而所在矩形的父节点上的对象(十字型的那些对象), 就只用检测处于左象限以及下象限的对象, 也就是省略了上半部和右半部。
这里代码表示有点绕, 就不亮出来了。
简单说 每个Rect新增 上下左右四象限的Boolean数据 表示对象是否处于 四部分象限内, 左下 就是 左 = true 下 = true
每个四叉树节点也新增 上下左右四象限 数据 如0号节点 他是右上象限, 那么他的 右 = true 上 = true
我们就是利用这些新增的数据来判断是否需要对返回的对象进行碰撞检测。

7.到这里 效率真的已经不错了,还有什么优化么?

的确还有。。。
这里我也思考了n就才发现, 帧数又提高了几帧。
在上面的优化之后, 我们只在对象改变节点位置的时候才重新插入, 每次插入insert的时候 我们都有用getIndex(rect) 来计算对象所处的节点位置,insert是一个递归方法, 他从根节点开始 一直计算到叶子节点,
getIndex算然计算量不大 但是我们可以省略这一步么?
是的。retrive方法每次都有计算对象的index, 而且保存在新增属性nowIndex里面, 所以我们插入的时候可以根据nowIndex计算得出。
给Rect新增一个level属性
每次计算index的时候

rect.level++;
复制代码
来保存对象所在的深度.
接着新增方法newInsert, 把方法上半部修改成这样:

if(nodes[0] != null)
{
        var index:int = rect.nowIndex >> (rect.level-1) * 2 & 3;
        rect.level--;
        if(rect.level > -1)
        {
                nodes[index].newInsert(rect);
                return;
        }
}

复制代码
注意是 & 而不是 &&

8.最后说说 想到的其他优化
把四叉树的对象列表Array改为Vector
试验后发现帧数反而下降了1,2帧。
研究后发现 Flash11里Array的效率提高了, Flash10里Array的Push Splice等操作都比Vector慢(至少是差不多 差一点的样子), 但是Flash11里Push操作居然比Vector还快一倍, 其他的方法又差不多, Splice则比Vector慢一倍。
所以最后不推荐把Array改成Vector

优化到这里, 终于大功告成, 真想不出还有什么优化了。
运行一看, 1000对象有47~50fps 真不错了。
再放到浏览器中运行一看, 居然60fps 无压力, 即使增加到1500对象也几乎是60fps。才发现 原来浏览器的FlashPlayer效率 比独立播放器高, OH Yeah!

写到这里真心已经精疲力尽 肝脑涂地 脑细胞死光光了,如果你觉得此文对你有帮助, 请多多评论,多多发言, 顶。

代码下载:
QuadTest.rar (4.65 KB, 下载次数: 326)

最后说下这个四叉树的bug 第5, 6, 7条优化会导致 碰撞不准确 所以我把5,6,7条优化 注释掉了 代码重新上传了 帧数因此下降了10fps
至于为什么会导致 这种结果 过阵子再研究 现在在折腾其他东西。
5,6,7条就先不删掉了 至少提供了几种思路。

Have fun ^_^


Please Star this Project if you like it! Follow would also be appreciated!
Peace!