使用四叉树优化碰撞检查

使用四叉树优化碰撞检查

·

4 min read

前情提要

大概是初中吧,我当时在给游戏开发模组,做了个界面,界面中面板之间要求不能堆叠,因为堆叠后不好处理点击事件等,我当时是通过遍历所有的面板去判断的,后来才知道可以用四叉树优化类似的需求。

还是得说一句,四叉树确实能优化这个需求,但提升不一定会明显,毕竟四叉树的时间复杂度是 logN,和 N 相比,只有在数据量大的时候才会有明显效果。

四叉树

那么四叉树是怎么提升效率的呢?先看一下原本的实现:

function intersects (range, cur) {
    return !(
      range.x - range.w > cur.x + cur.w ||
      range.x + range.w < cur.x - cur.w ||
      range.y - range.h > cur.y + this.h ||
      range.y + range.h < cur.y - cur.h
    );
  }

const handleDrag = (id, x, y, isDragging) => {
    const cur = allItems.find(item => item.id === id)
  const other = allItems.filter(item => item.id !== id);
  other.forEach(item => {
    if (intersects(item, cur)) {
      // 碰撞
    }
  })
}

这是最直观的做法,遍历除了当前元素之外的那些,然后一个个做检测,handleDrag 本身就是高频调用的函数,如果 allItems 比较大,效率影响还是比较明显的。

handleDrag 本身加上节流是不合理的,因为这样做直接影响了拖拽操作的流畅度,所以应该减少每一次遍历时 allItems 的数量,而四叉树就是按这个思路优化的。

可以用二叉树来理解,四叉树就是一个节点最多有 4 个子节点的树型结构:

将元素按位置规划到不同的节点下,当元素的数量超过节点负载时,将节点继续划分成新的四叉树,查询的时候就不需要遍历全部元素,只需要找到当前所在节点,然后查询节点内部的元素即可。

class QuadTree {
  constructor (boundary, capacity) {
    this.boundary = boundary
    this.capacity = capacity
    this.nodes = []
    this.points = []

  }

    // 对当前节点进行划分
  divide () {
    const x = this.boundary.x
    const y = this.boundary.y
    const width = this.boundary.width / 2
    const height = this.boundary.height / 2

    this.nodes[0] = new QuadTree({
      x,
      y,
      width,
      height
    }, this.capacity)
    this.nodes[1] = new QuadTree({
      x: x + width,
      y,
      width,
      height
    }, this.capacity)
    this.nodes[2] = new QuadTree({
      x,
      y: y + height,
      width,
      height
    }, this.capacity)
    this.nodes[3] = new QuadTree({
      x: x + width,
      y: y + height,
      width,
      height
    }, this.capacity)
  }

    // 判断块是否和当前节点有交集
    // emmmm,命名有些问题
  contains (point) {
    const left = point.x
    const right = point.x + point.width
    const top = point.y
    const bottom = point.y + point.height
    const isOutside = (
      left > this.boundary.x + this.boundary.width ||
      right < this.boundary.x ||
      top > this.boundary.y + this.boundary.height ||
      bottom < this.boundary.y
    )
    if (isOutside) {
      return false
    }
    return true
  }

    // 往子节点插入块
  #insertToChildren (point) {
    for (let i = 0; i < this.nodes.length; i++) {
      const node = this.nodes[i]
      if (node.contains(point)) {
        node.insert(point)
        return
      }
    }
  }

  // 插入新块
  insert (point) {
    const isLastNode = this.nodes.length === 0
    if (!isLastNode) {
      this.#insertToChildren(point)
      return
    }
    if (this.points.length < this.capacity) {
      this.points.push(point)
      return
    }
    this.divide()
    for (let i = 0; i < this.points.length; i++) {
      const point = this.points[i]
      this.#insertToChildren(point)
    }
    this.#insertToChildren(point)
    this.points = []
  }

    // 查询某个块周围的块
  query (point, result = []) {
    if (!this.contains(point)) {
      return result
    }
    const isLastNode = this.nodes.length === 0
    if (isLastNode) {
      return this.points
    }
    for (let i = 0; i < this.nodes.length; i++) {
      const node = this.nodes[i]
      const points = node.query(point)
      result.push(...points)
    }

    return result.filter((pt, index) => {
      return result.indexOf(pt) >= index
    })
  }
}

这是四叉树的实现,QuadTree 是一棵四叉树,同时也作为节点,而参数中的 boundary 则表示存在四叉树中的对象,他没有限定类型,但他必须在本文的例子中必须实现以下接口

interface Boundary {
  x: number
  y: number
  width: number
  height: number
}

使用起来是这样的:

// 单个节点的块容量
const CAPACITY = 4
// 创建节点作为四叉树的根
const qt = new QuadTree({
  x: 0,
  y: 0,
  // 根节点的尺寸
  width: 1920,
  height: 1080
}, CAPACITY)
// 插入块
qt.insert(point1)
qt.insert(point2)
// 查询周围的块
const nearests = qt.query(movingPoint)

(这里我的变量命名可能有点问题,不应该叫 points,叫 rects 更合适,毕竟对象都是矩形块)

构建与插入

先是创建一个 QuadTree 节点 qt,同时作为一整棵四叉树,接着把 point 通过 insert 插入到四叉树中,让四叉树自动调整自身结构insert 的流程如下:

  1. point 为参数

  2. 先判断当前节点是否为叶子节点,如果不是就遍历所有子节点,往子节点插入 point(也是调用 insert

  3. 如果是叶子节点,那么先检查当前节点的块余量有余量就直接往当前节点插入 point

  4. 否则对当前节点进行再一次划分,划分出左上、右上、左下、右下四块

  5. 再把当前节点的所有块和参数 point 插入到子节点(此时当前的已经不是叶子节点了)

  6. 清空当前节点的所有块

insert 的流程中四叉树节点(可能)会自动进行划分,所以一棵完整的四叉树,内部可能会划分拆成多层,这里可以加多一个 maxLevel 控制层级上限,避免划分得过于细致,出现新的性能问题。

查询

(查询是不需要重新构建四叉树的,除非内部已有的点是动点,这种情况有特殊的处理办法)

查询的参数 point 可以理解成是结构中的唯一动点(在本文静态结构中),查的是和 point 有交集的节点中的其他块,而且这个节点范围是尽量小的,如图:

参数 point绿块,看起来两个红节点都和绿块有交集,但实际上 query 会在左侧的红节点左侧的两个蓝节点中进行查询,右侧的两个蓝节点不会受父节点的影响而被检查

query 的流程是这样的:

  1. point 作为参数,result 用于递归的时候保存结果,手动调用时不需要传

  2. 还是先判断当前是否叶子节点,如果是,那么就返回他的所有块

  3. 如果不是叶子节点,那么遍历他所有的子节点,让子节点进行查询(通过 query

    1. 把和 point 有交集的后代节点的块都存入 result
  4. 最后对 result 进去去重,返回

实践

这个例子会结合“找出附近的其他元素”需求来开发。

const initItems = [
    {
      "id": "element28",
      "x": 522,
      "y": 523,
      "width": 73,
      "height": 36
  }
]

先做一些假的块数据,尽量分散。

function DragItem (props) {
  const { move, stop, start } = props

  const handleDrag = React.useCallback((e, data) => {
    move(props.id, data.x, data.y)
  }, [move, props.id])

  return (
    <Draggable
      position={{
        x: props.x,
        y: props.y
      }}
      onStart={start}
      onDrag={handleDrag}
      onStop={stop}>
      <div>
        <div
          className="handle"
          style={{
            width: props.width,
            height: props.height,
          }}
        >{props.id}</div>
      </div>
    </Draggable>
  )
}

再实现一个可以拖动的组件(这里我使用的是 react-draggable),movestartstop 由父组件统一管理。

function App() {
  const [items, setItems] = React.useState(initItems)
  const [draggingId, setDraggingId] = React.useState('')
  const qt = React.useRef(null)

  const start = React.useCallback((id) => {
    setDraggingId(id)
  }, [])

  const move = React.useCallback((id, x, y) => {
    const newItems = items.map((item) => {
      if (item.id === id) {
        return {
          ...item,
          x,
          y
        }
      }
      return item
    })
    setItems(newItems)
    setDraggingId(id)
  }, [items])

  const rebuildQuadTree = React.useCallback(() => {
    const boundary = {
      x: 0,
      y: 0,
      width: window.innerWidth,
      height: window.innerHeight
    }
    const capacity = 4
    const quadTree = new QuadTree(boundary, capacity)
    items.forEach((item) => {
      quadTree.insert(item)
    })
    qt.current = quadTree
    console.log(quadTree)
  }, [items])

  const stop = React.useCallback(() => {
    if (draggingId) {
      rebuildQuadTree()
      setDraggingId('')
    }
  }, [draggingId, rebuildQuadTree])

  const nearestItems = React.useMemo(() => {
    if (!draggingId || !qt.current) {
      return []
    }
    const draggingItem = items.find((item) => item.id === draggingId)
    if (!draggingItem) {
      return []
    }
    const result = qt.current.query(draggingItem)
    return result
  }, [draggingId, items])

  React.useEffect(() => {
    rebuildQuadTree()
  }, [])

  return (
    <>
      <div className="container">
        {items.map((item) => {
          return (
            <DragItem
              key={item.id}
              id={item.id}
              x={item.x}
              y={item.y}
              width={item.width}
              height={item.height}
              start={start}
              move={move}
              stop={stop}
            />
          )
        })}
      </div>
      <div className="nearest">
        {nearestItems.map((item) => {
          return (
            <div key={item.id}>{item.id}</div>
          )
        })}
      </div>
    </>
  )
}

draggingId 表示当前拖动元素的 id,在 startstop 时设置,这个很直观吧。

rebuildQuadTree 用于构建一棵新的四叉树,构建的时机是初始化时和 stop 后,因为元素拖动结束,位置发生了变化,所以需要调整四叉树的数据。

nearestItems 是当前被拖动的节点 p 附近的元素,在 p 移动的时候会通过 query 在四叉树中实时查询。

在加上辅助组件之后大概是下图的效果:

在线 Demo