物理引擎学习08-AABB树

AABB树是由AABB包围盒结点构成的二叉树,常用来加速场景中的射线检测和碰撞检测。树的每个结点都是一个包围盒,且结点的包围盒包裹了所有子结点的包围盒。本文深入的讲解了AABB树相关的算法,以及结合物理引擎使用。

本文作者游蓝海。原创不易,未经许可,禁止任何形式的转载。

1. 概述

  • AABB。Axis Aligned Bounding Box,轴对齐包围盒。与AABB相对应的有OBB(Oriented Bounding Box),方向包围盒。
  • AABB树。由AABB结点构成的二叉树。树的每个结点都是一个包围盒,且结点的包围盒包裹了所有子结点的包围盒。由于是二叉树,则碰撞查询的时间复杂度是 O ( l o g ( n ) ) O(log(n)) O(log(n))
  • BVH。Bounding Volumn Heirarchy,层次包围盒结构。描述的是一种基于对象包围盒构成的树状结构,用来加速射线检测和碰撞检测等耗时的计算。AABB树和OBB树,都属于BVH结构。

如图所示,AABB树是一颗满二叉树,对象只存在于叶结点中,父结点的包围盒包含了子结点的包围盒。
AABB树

举例

有三个几何体A、B、C。A和B组成一个结点N,然后N和C组成了根结点Root:
AABB树2
当执行碰撞检测时,设待检测几何体为X:

  1. 如果X的包围盒与Root的包围盒相交,则需要递归检测X与Root的两个子结点是否相交;否则,无需继续向下检测了。
  2. 如果X的包围盒与C的包围盒相交,则需要进一步检测X与C是否相交,来判断X是否与C发生了碰撞;否则,无需与C继续检测了。
  3. X与N的碰撞检测,和X与Root的碰撞检测方法类似。

与空间划分树的区别

  • AABB树。是基于对象包围盒的树状结构,每个几何体只存在于一个结点中,不存在对象被切割的情况。
  • 空间划分树。是基于空间分割的,把空间分割成2部分的叫二分空间树(BSP),4部分的叫四叉树,8部分的叫八叉树。如果一个对象跨越了边界,则物体要被切割成两部分,分别加入边界的两侧。

优点

  • 生成的速度快;
  • 动态插入和删除效率高;
  • 结点数量是可控的;
  • 碰撞查询不会重复与几何体进行检测。

缺点

  • 射线检测效率不如空间划分树。因为空间划分可以明确的把射线分成两部分,只要正面发生碰撞,另一侧就不需要检测了。

2. AABB树的操作

新结点插入算法

插入算法,本质上就是对二叉树的插入。设根结点为root,待插入结点设为p,算法步骤为:

  • 找到合适的插入位置c,此时c必然是一个叶结点;
  • 新建一个结点n,并将c和p分别作为n的左右子结点;
  • 然后用n替代原来c的位置;
  • 重新平衡二叉树,避免退化成链表;
  • 重新计算n以及其父结点的包围盒。

算法伪代码如下:

function insertNode(root, p)
    // 查找可插入的位置c
    c = root
    while not c.isLeaf do
        c = getBestParent(c.left, c.right, p)
    end
    
    // 新建一个结点n,作为c和p的父结点
    n = newNode(left = c, right = p, parent = c.parent)

    // 用n取代c的位置
    if c == n.parent.left then
        n.parent.left = n
    else
        n.parent.right = n
    end
    
    balanceNode(n)
    
    // 自底向上,重新计算包围盒和高度。updateBoundsBottomUp
    c = n
    while c do
      c.aabb = c.left.aabb + c.right.aabb
      c.height = 1 + max(c.left.height, c.right.height)
      c = c.parent
    end
end

平衡二叉树

当插入和删除结点后,二叉树可能会失去平衡,导致查询效率变低。可以在插入和删除后,使用平衡二叉树或红黑树的机制,进行旋转,使二叉树尽可能的保持平衡。也可以增加一个操作阈值,达到一定的操作次数后,重新构建整棵树。

如何选择最佳父结点

这里使用了Chipmunk物理引擎中的算法。

  1. 增加结点后,尽量不扩充或少扩充原包围盒的面积。如下图所示,如果P加入A结点中,A的面积并不会扩充;如果加入B结点中,B的面积将会扩充为BP,使得总面积增大。

    可以使用如下公式进行判断,如果公式结果为真,则加入A结点;否则,加入B结点:
    S ( P + A ) + S ( B ) < S ( P + B ) + S ( A ) S(P + A) + S(B) < S(P + B) + S(A) S(P+A)+S(B)<S(P+B)+S(A)

  2. 如果,条件1中的两个结点的扩充面积相等时,可以优先考虑中心点比较接近的情况。可以使用公式进行判断:
    ∣ C ( P ) − C ( A ) ∣ < ∣ C ( P ) − C ( B ) ∣    ⟹    ∣ C x ( P ) − C x ( A ) ∣ + ∣ C y ( P ) − C y ( A ) ∣ < ∣ C x ( P ) − C x ( B ) ∣ + ∣ C y ( P ) − C y ( B ) ∣ 已 知 : C x ( P ) = P x m i n + P x m a x 2 |C(P) - C(A)| < |C(P) - C(B)| \\ \implies |C_x(P) - C_x(A)| + |C_y(P) - C_y(A)| < |C_x(P) - C_x(B)| + |C_y(P) - C_y(B)| \\ 已知: C_x(P) = \frac{P_{xmin} + P_{xmax}}{2} C(P)C(A)<C(P)C(B)Cx(P)Cx(A)+Cy(P)Cy(A)<Cx(P)Cx(B)+Cy(P)Cy(B):Cx(P)=2Pxmin+Pxmax

算法伪代码如下:

function getBestParent(a, b, p)
    w1 = (p.aabb + a.aabb).area + b.aabb.area
    w2 = (p.aabb + b.aabb).area + a.aabb.area
    if w1 < w2 then
        return a
    elseif w1 > w2 then
        return b
    end
    
    w1 = abs(p.aabb.xMin + p.aabb.xMax - a.aabb.xMin - a.aabb.xMax) +
         abs(p.aabb.yMin + p.aabb.yMax - a.aabb.yMin - a.aabb.yMax);
    w2 = abs(p.aabb.xMin + p.aabb.xMax - b.aabb.xMin - b.aabb.xMax) +
         abs(p.aabb.yMin + p.aabb.yMax - b.aabb.yMin - b.aabb.yMax);
    if w1 < w2 then
        return a
    else
        return b
    end
end

结点删除算法

删除算法,就是插入算法逆过程。每次直接删除的都是叶结点,删除后,其父结点就只剩一个子结点了,此时的父结点也可以一并删除了,然后用兄弟结点替代父结点的位置。

function removeNode(node)
    parent = node.parent
    
    // 只有根结点没用父结点
    if not parent then
        root = null
        return
    end
    
    if node == parent.left then
        neighbour = parent.right
    else
        neighbour = parent.left
    end
    
    grandParent = parent.parent
    neighbour.parent = grandParent
    if not grandParent then
        root = neighbour
    else
        if parent == grandParent.left then
            grandParent.left = neighbour
        else
            grandParent.right = neighbour
        end
        
        updateBoundsBottomUp(grandParent)
    end
end

碰撞检测算法

跟射线检测类似,如果当前结点的包围盒与待检测几何体相交时,需要对左右结点分别执行碰撞检测。

function queryCollision(node, shape, output)
    if not node.aabb.intersect(shape.aabb) then
        return
    end
    
    if node.isLeaf then
        if node.shape.intersect(shape) then
            output.hit = true
            output.nodes.append(node)
        end
    else
        queryCollision(node.left, shape, output)
        queryCollision(node.right, shape, output)
    end
end

射线检测算法

如果当前结点的包围盒与射线相交时,需要对左右子结点分别进行射线检测,取相交距离最近的结点作为结果。

function raycast(node, ray, output)
    // 如果与包围盒不相交,则不用向下检测了
    if not node.aabb.intersect(ray) then
        return
    end

    if node.isLeaf then
        hit, distance = node.shape.intersect(ray)
        if hit and distance < output.distance then
            output.hit = true
            output.distance = distance
            output.node = node
        end
    else
        raycast(node.left, ray, output)
        raycast(node.right, ray, output)
    end
end

射线检测优化

如果射线到某结点的包围盒的距离,大于已经检测出的最近距离,则说明射线不可能与当前结点以及其子结点相交。

if node.aabb.getDistance(ray) < output.distance then
    raycast(node, ray, output)
end

getDistance方法返回了射线到aabb的最近距离,如果射线与aabb不相交,则可以返回一个很大的数字,使分支不会执行到。

完整算法伪代码:

function raycast(node, ray, output)
    if node.isLeaf then
        hit, distance = node.shape.intersect(ray)
        if hit and distance < output.distance then
            output.hit = true
            output.distance = distance
            output.node = node
        end
        return
    end
    
    d1 = node.left.aabb.getDistance(ray)
    d2 = node.right.aabb.getDistance(ray)
    if d1 < d2 then
        // 射线离左结点包围盒更近,优先检测左结点
        if d1 < output.distance then
            raycast(node.left, ray, output)
        end
        if d2 < output.distance then
            raycast(node.right, ray, output)
        end
    else
        // 射线离右结点包围盒更近,优先检测右结点
        if d2 < output.distance then
            raycast(node.right, ray, output)
        end
        if d1 < output.distance then
            raycast(node.left, ray, output)
        end
    end
end

重构满二叉树

当从已有的几何体数组构建AABB树时,可以使用分治法进行构建,得到一颗满二叉树。算法核心步骤为:

  1. 找到一个分割点,将数组分割成两部分;
  2. 然后分别对左右两侧的数组,递归执行构建算法;
  3. 如果数组中只剩下1个或2个结点时,递归结束,生成叶结点。
function rebuild(shapes, start, count)
    if count == 1 then
        return newLeafNode(shapes[start])
    end
    if count == 2 then
        return newShapeNode(shapes[start], shapes[start+1])
    end
    
    pos = findPartition(shapes, start, count)
    left = rebuild(shapes, start, pos)
    right = rebuild(shapes, pos, count - pos)
    return newNode(left, right)
end

查找分割点

可以按照x或y轴方向,计算出该方向上坐标值的中位数。然后将小于中位数的元素,移动到数组左侧;大于中位数的元素,移动到数组的右侧。

3. 使用AABB树优化碰撞检测

二叉树的查询效率为 O ( l o g ( n ) ) O(log(n)) O(log(n)),因此,要检测出所有几何体与哪些几何体发生了碰撞,需要的时间复杂度为 O ( n   l o g ( n ) ) O(n\ log(n)) O(n log(n))。这要比原本两层for循环的时间复杂度 O ( n 2 ) O(n^2) O(n2)好很多了。

function queryCollisionPairs()
    for shape in shapes do
        output = {
            hit = false,
            nodes = [],
        }
        aabbTree.queryCollision(shape, output)
    end
end

更近一步优化

碰撞只产生于正在运动的物体,那些没有运动的物体根本就不需要检测了。基于这一点,有两个优化方向:

  1. 活跃列表。维护一个处于运动状态的刚体列表,每次做碰撞检测的时候,仅检查这个列表中的刚体。
  2. 动静分离。使用两个AABB树,一个用来记录静态几何体,比如围墙、地面等不会运动的物体;另一个用来记录动态几何体,比如NPC、子弹等会发生移动的物体。这样可以减少静态几何体结点的插入删除次数,也能有效降低动态AABB树的深度,进而提高查询效率。

与空间划分树结合使用

对于超大场景,可以使用分治法策略,将场景在空间上划分成若干个区域,每个区域单独维护一套AABB树。这样做可以有效降低单棵树的结点数量,也可避免局部物体移动,引发大范围结点变动。若使用基于网格的空间划分策略,可以在O(1)的时间复杂度下,快速定位到几何体属于那棵AABB树,能进一步提升查询效率。

function queryCollisionPairs()
    for shape in activeShapes do
        output = {
            hit = false,
            nodes = [],
        }
        // 如果shape跨越了边界,需要检测多个区域
        chunks = queryChunks(shape)
        for chunk in chunks do
            chunk.aabbTree.queryCollision(shape, output)
        end
    end
end

4. 小结

AABB树是由AABB结点构成的满二叉树。树的每个结点都是一个包围盒,且结点的包围盒包裹了所有子结点的包围盒

本章Demo使用Unity3D引擎开发,Demo工程已上传github: https://github.com/youlanhai/learn-physics/tree/master/Assets/08-aabb-tree

在这里插入图片描述

5. 参考

  • Chipmunk2D: https://chipmunk-physics.net/release/ChipmunkLatest-Docs/

本系列文章会和我的个人公众号同步更新,感兴趣的朋友可以关注下我的公众号:游戏引擎学习。扫下面的二维码加关注:
游戏引擎学习

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值