使用四叉树优化碰撞检查
前情提要
大概是初中吧,我当时在给游戏开发模组,做了个界面,界面中面板之间要求不能堆叠,因为堆叠后不好处理点击事件等,我当时是通过遍历所有的面板去判断的,后来才知道可以用四叉树优化类似的需求。
还是得说一句,四叉树确实能优化这个需求,但提升不一定会明显,毕竟四叉树的时间复杂度是 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
的流程如下:
point
为参数先判断当前节点是否为叶子节点,如果不是就遍历所有子节点,往子节点插入
point
(也是调用insert
)如果是叶子节点,那么先检查当前节点的块余量,有余量就直接往当前节点插入
point
否则对当前节点进行再一次划分,划分出左上、右上、左下、右下四块
再把当前节点的所有块和参数
point
插入到子节点(此时当前的已经不是叶子节点了)清空当前节点的所有块
在 insert
的流程中四叉树节点(可能)会自动进行划分,所以一棵完整的四叉树,内部可能会划分拆成多层,这里可以加多一个 maxLevel
控制层级上限,避免划分得过于细致,出现新的性能问题。
查询
(查询是不需要重新构建四叉树的,除非内部已有的点是动点,这种情况有特殊的处理办法)
查询的参数 point 可以理解成是结构中的唯一动点(在本文静态结构中),查的是和 point 有交集的节点中的其他块,而且这个节点范围是尽量小的,如图:
参数 point
为绿块,看起来两个红节点都和绿块有交集,但实际上 query
会在左侧的红节点和左侧的两个蓝节点中进行查询,右侧的两个蓝节点不会受父节点的影响而被检查。
query
的流程是这样的:
让
point
作为参数,result
用于递归的时候保存结果,手动调用时不需要传还是先判断当前是否叶子节点,如果是,那么就返回他的所有块
如果不是叶子节点,那么遍历他所有的子节点,让子节点进行查询(通过
query
)- 把和 point 有交集的后代节点的块都存入 result
最后对 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),move
、start
和 stop
由父组件统一管理。
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,在 start
和 stop
时设置,这个很直观吧。
rebuildQuadTree
用于构建一棵新的四叉树,构建的时机是初始化时和 stop
后,因为元素拖动结束,位置发生了变化,所以需要调整四叉树的数据。
nearestItems
是当前被拖动的节点 p 附近的元素,在 p 移动的时候会通过 query
在四叉树中实时查询。
在加上辅助组件之后大概是下图的效果: