空间搜索优化算法之——四叉树

2,069 阅读3分钟

我正在参与掘金创作者训练营第4期,点击了解活动详情,一起学习吧!

前言

在游戏开发中,我们通常会遇到这样的一类问题:碰撞检测,比如我们开发一款飞机大战类的游戏,需要检测飞机是否与子弹碰撞到了一起。今天我们就来谈谈如何对此类问题进行优化。

碰撞算法

朴素算法

首先,最容易让人想到的情况就是让所有需要进行碰撞检测的物体两两配对进行碰撞检测。那么这种效率想必是非常低的。


collid(): void {
    for (let i = 0; i < this.colliders.length; i++) {
        const collider1 = this.colliders[i];
        for (let j = 0; j < this.colliders.length; j++) {
            const collider2 = this.colliders[j];
            if (collider1 === collider2) {
                continue;
            }
            if (this.checkCollision(collider1.node, collider2.node)) {
                collider1.onCollision();
                collider2.onCollision();
            }
        }
    }
}

那么有没有一种算法可以提高我们的碰撞检测效率呢?答案是肯定的,今天就为大家介绍一种经常在空间搜索中使用的一种数据结构:四叉树

首先我们先来看看不用四叉树和用四叉树之间他们的效率对比如何:

不使用四叉树:

image.png 使用四叉树:

image.png

对1000个物体进行两两的碰撞检测:

  • 在不使用四叉树的情况下平均帧率只有31帧左右。
  • 使用四叉树的情况下平均帧率在60帧左右。

四叉树

什么是四叉树

我以二叉树来进行类比,对于二叉树来说,一个根节点下具有两个子节点。那么四叉树顾名思义,就是一个根节点下具有四个子节点。

image.png

image.png

那么,我们使用四叉树对于优化空间搜索有什么帮助呢?

Key Idea:

其中核心的思想就在于我们可以将一个平面空间划分为“4个区域”,分别对应的就是四叉树中的4个子节点。

image.png

如上图所示:图中的蓝色小点就是我们需要进行碰撞检测的对象,那么在上述平面空间中,蓝色小点越密集的地方,我们就将空间划分的越细致,直到满足我们设定的某一标准(比如当前子空间的碰撞体个数小于某个值,或者四叉树的深度达到某个值后就不再进行划分了)。

另外一般来说,我们只在四叉树的叶子节点中存储碰撞对象

那么,完成了四叉树的构建之后,我们再进行碰撞检测的时候就方便多了。我们只需要检测当前这个点和其子空间的其他点就可以了,大大减少了不必要的碰撞检测。

四叉树的问题

但是四叉树有一个问题:如果一个物体它不仅仅处于四叉树的边界时,如下图所示。此时针对这个大蓝色方块的碰撞检测,我们既需要与区域1中的物体进行碰撞检测,也需要与区域2中的物体进行碰撞检测。

image.png

实现一个四叉树

实现一个四叉树的核心要点就在于往四叉树中“插入元素”和“获取某个区域中的所有元素”了,就是“增删改查”中的”增“ 和 ”查“。

    public constructor(
        bounds: Bounds,
        max_objects?: number,
        max_levels?: number,
        level?: number
    ) {
        //*节点最大容量
        this.max_objects = max_objects || 10;
        //*四叉树深度
        this.max_levels = max_levels || 4;

        this.level = level || 0;
        this.bounds = bounds;

        this.objects = [];
        this.nodes = [];
    }

首先,我们先来写一下其构造函数,我们设计如下:

  1. 设定四叉树的最大深度,如果达到最大深度后,四叉树不再继续分裂。
  2. 设定其每一层能够容纳的最大碰撞体个数,达到数量后,四叉树进行分裂。
  3. level表示当前四叉树节点的深度
  4. bounds表示当前四叉树节点的边界位置
  5. nodes为实际存储碰撞体的数组,该属性只有在叶子节点中才应该有数据。

现在,我们来编写核心方法:往四叉树中插入元素。

插入元素


    /**
     * *插入一个node
     * @param Object pRect        bounds of the object to be added { x, y, width, height }
     */
    insert(node: cc.Node) {
        var i = 0,
            indexes;
        //if we have subnodes, call insert on matching subnodes
        if (this.nodes.length) {
            indexes = this.getIndex(node);

            for (i = 0; i < indexes.length; i++) {
                this.nodes[indexes[i]].insert(node);
            }
            return;
        }

        //otherwise, store object here
        this.objects.push(node);
        this.insertNode++;

        //max_objects reached
        if (
            this.objects.length > this.max_objects &&
            this.level < this.max_levels
        ) {
            //split if we don't already have subnodes
            if (!this.nodes.length) {
                this.split();
            }

            //add all objects to their corresponding subnode
            for (i = 0; i < this.objects.length; i++) {
                indexes = this.getIndex(this.objects[i]);
                for (var k = 0; k < indexes.length; k++) {
                    this.nodes[indexes[k]].insert(this.objects[i]);
                }
            }

            //clean up this node
            this.objects = [];
        }
    }

现在,我们来逐句分析源代码

//if we have subnodes, call insert on matching subnodes
if (this.nodes.length) {
    indexes = this.getIndex(node);

    for (i = 0; i < indexes.length; i++) {
        this.nodes[indexes[i]].insert(node);
    }
    return;
}

该分支条件表示的是:如果该节点不是叶子节点,通过getIndex 方法来获取当前碰撞元素与哪些子节点相交,在这些子节点中递归的插入这个碰撞元素。

getIndex 方法如下所示,大致的逻辑就是一些空间中位置划分,详细的逻辑就不过多赘述了。

    /**
     * *判断node属于哪一个区域(象限)
     * @param Object pRect      bounds of the area to be checked, with x, y, width, height
     * @return Array            an array of indexes of the intersecting subnodes
     *                          (0-3 = top-right, top-left, bottom-left, bottom-right / ne, nw, sw, se)
     */
    getIndex(node: cc.Node): number[] {
        let pRect = node.getBoundingBox();
        var indexes: number[] = [],
            verticalMidpoint = this.bounds.x + this.bounds.width / 2,
            horizontalMidpoint = this.bounds.y + this.bounds.height / 2;

        var startIsNorth = pRect.y < horizontalMidpoint,
            startIsWest = pRect.x < verticalMidpoint,
            endIsEast = pRect.x + pRect.width > verticalMidpoint,
            endIsSouth = pRect.y + pRect.height > horizontalMidpoint;

        //top-right quad
        if (startIsNorth && endIsEast) {
            indexes.push(0);
        }

        //top-left quad
        if (startIsWest && startIsNorth) {
            indexes.push(1);
        }

        //bottom-left quad
        if (startIsWest && endIsSouth) {
            indexes.push(2);
        }

        //bottom-right quad
        if (endIsEast && endIsSouth) {
            indexes.push(3);
        }

        return indexes;
    }

我们现在继续回到insert方法中的主逻辑中:


//otherwise, store object here
this.objects.push(node);

//max_objects reached
if (
    this.objects.length > this.max_objects &&
    this.level < this.max_levels
) {
    //split if we don't already have subnodes
    if (!this.nodes.length) {
        this.split();
    }

    //add all objects to their corresponding subnode
    for (i = 0; i < this.objects.length; i++) {
        indexes = this.getIndex(this.objects[i]);
        for (var k = 0; k < indexes.length; k++) {
            this.nodes[indexes[k]].insert(this.objects[i]);
        }
    }

    //clean up this node
    this.objects = [];
}

如果该节点是叶子节点,那么我们直接往 objects 数组中push该碰撞对象。

接着,我们需要判断当前节点中保存的碰撞对象是否超过了我们允许保存的对象数量且当前节点深度没有超过我们设定的最大深度,如果满足上述的两个条件,那么我们需要将当前四叉树进行分裂!分裂四叉树的逻辑在 split 函数中。


    split() {
        var nextLevel = this.level + 1,
            subWidth = this.bounds.width / 2,
            subHeight = this.bounds.height / 2,
            x = this.bounds.x,
            y = this.bounds.y;

        //top right node
        this.nodes[0] = new Quadtree(
            {
                x: x + subWidth,
                y: y,
                width: subWidth,
                height: subHeight,
            },
            this.max_objects,
            this.max_levels,
            nextLevel
        );

        //top left node
        this.nodes[1] = new Quadtree(
            {
                x: x,
                y: y,
                width: subWidth,
                height: subHeight,
            },
            this.max_objects,
            this.max_levels,
            nextLevel
        );

        //bottom left node
        this.nodes[2] = new Quadtree(
            {
                x: x,
                y: y + subHeight,
                width: subWidth,
                height: subHeight,
            },
            this.max_objects,
            this.max_levels,
            nextLevel
        );

        //bottom right node
        this.nodes[3] = new Quadtree(
            {
                x: x + subWidth,
                y: y + subHeight,
                width: subWidth,
                height: subHeight,
            },
            this.max_objects,
            this.max_levels,
            nextLevel
        );
    }

split 函数中仅仅只做了空间划分的逻辑,其保证了函数的单一职责,这一点是我们需要值得注意的地方,大家以后在编写其他函数的时候同样需要注意函数的单一职责原则。

我们继续回到 insert主逻辑中来:

    //add all objects to their corresponding subnode
    for (i = 0; i < this.objects.length; i++) {
        indexes = this.getIndex(this.objects[i]);
        for (var k = 0; k < indexes.length; k++) {
            this.nodes[indexes[k]].insert(this.objects[i]);
        }
    }

    //clean up this node
    this.objects = [];    

与之前的逻辑类似,我们通过 getIndex 方法来得到碰撞元素在哪些子节点中,然后在这些子节点中插入这些碰撞元素。

最后,我们需要将该节点中的 objects对象清空。

查询元素

现在,我们来实现:给定碰撞元素,查询与该碰撞元素在同一区域的所有元素的功能。代码如下:


    retrieve(node: cc.Node) {
        var indexes = this.getIndex(node),
            returnObjects = this.objects;

        //if we have subnodes, retrieve their objects
        if (this.nodes.length) {
            for (var i = 0; i < indexes.length; i++) {
                returnObjects = returnObjects.concat(
                    this.nodes[indexes[i]].retrieve(node)
                );
            }
        }

        //remove duplicates
        returnObjects = returnObjects.filter(function (item, index) {
            return returnObjects.indexOf(item) >= index;
        });

        return returnObjects;
    }
    

其基本的逻辑为:通过getIndex 方法获取到当前碰撞元素所在的区域,注意,这里的区域可能不是一个,原因在上文已经提到过,一个碰撞元素可能处于几个区域的边界处。所以,我们需要将所有区域中的元素获取出来并且合并在一起。

并且,由于元素跨区域的原因,所以我们获取到的元素可能是有重复的,所以我们需要对最后的结果进行去重处理。

以上的代码就是我们四叉树的核心代码了,希望读者可以根据上述的代码自行整理代码,这也是一个不断学习的过程。

四叉树的实践应用

我们现在已经完成了四叉树类,在使用时我们通过不断的 构建四叉树 --> 查询 来进行碰撞优化。


protected lateUpdate(): void {
    if (this.mode === "quad") {
        this.rebuildQuadTree();
        this.collideWithQuadTree();
    } else {
        this.collid();
    }
}


collideWithQuadTree(): void {
    for (let i = 0; i < this.colliders.length; i++) {
        const collider1 = this.colliders[i].node;
        const others = this.quadTree.retrieve(collider1);
        for (let j = 0; j < others.length; j++) {
            const collider2 = others[j];
            if (collider1 === collider2) {
                continue;
            }
            if (this.checkCollision(collider1, collider2)) {
                collider1.getComponent(ColliderControl).onCollision();
                collider2.getComponent(ColliderControl).onCollision();
            }
        }
    }
}

在上述代码中,lateUpdate 是一个每一帧都会执行的函数,在每一帧中,我们都需要去重新构建四叉树(因为场景中的碰撞元素是时时刻刻在发生变化的)。然后在对每个碰撞元素进行查询操作,最后进行碰撞检测。

最终的结果正如文章开头所示的那样,此处就再展示了。

总结

今天学习了使用四叉树来对碰撞检测进行优化,那么你还能想到哪些可以使用四叉树的场景呢?另外,本文中每一帧都对四叉树进行了重建,这是否是一种消耗性能的操作呢?我们是否可以对四叉树进行动态的构建呢?这些都是本文中遇到的一些问题,这些问题我将在后续的文章中继续说明。

如果你觉得本文有用,请别忘了为作者点赞,你的赞赏就是作者更新的动力,下期再见~