【Unity】经典四叉树的实现以及和无空间划分加速下的效率对比分析
背景
假如场景中存在大量的对象,需要快速找到某个范围内的所有对象,如果通过传统的方式,就需要对所有的物体遍历,依次判断是否在范围中,这样非常耗时。所以通过空间划分的方法将其加速,本文中采用四叉树的方式,从实现思想和代码层面对效率进行分析。
思想
空间划分算法通过将空间或场景划分为多个区域或单元,以便更高效地管理空间中的对象和事件。四叉树通过将已知空间等分为四个相等的子空间,然后递归地进行分割,直到满足一定条件或达到预设的深度。
文本中四叉树的数据结构定义为QuadTreeNode,其包含如下成员:
- 该节点管辖的区域
Bounds bound
,包含位置,尺寸两个信息 - 该节点管理的对象列表
List<Object> objects
- 该节点是否被划分的标记
bool isDivide
- 该节点包含的四个子区域
QuadTreeNode[4] children
- 当前节点所在的深度
int depth
。
其主要操作有:
- 创建(new):本文采用深度限制作为收敛条件,所以需要规定这个树最大的深度
maxDepth
,文章中为画图方便,设定为3层。 - 插入(Insert):见下文。
- 搜索(Query):见下文。
- 移除(Remove):未实现。
- 更新(Update):用于对象动态移动的情况,每次移动需要对树进行实时的移除和插入,未实现。
插入
对于每个对象,都执行以下的插入操作:
(1)首先将一个物体放入一个定义了边界的空间,如果这个物体和该区域有重叠,则继续执行。
(2)在插入前,如果当前层没有到达最深的层,则将该区域划分成四个子区域。如果当前已在最深的层,则插入到该层进行维护。(对于压线情况见下文区分探讨)
(3)对每个子区域,都递归地执行(1)。
压线处理
假如物体和某层的划分线重叠了,一般有两种处理方式。本文采用的是方式2。
-
方式1: 由两个子区域分别维护,这样的缺点是比较浪费内存空间。
-
方式2: 由当前层维护,不插入到子区域。
按照方式2完成了所有对象的插入后,四叉树会成为如下这样的结构:

搜索
搜索一片区域时,从四叉树根节点出发,通过以下方式进行:
(1)判断搜索区域是否和该节点管辖区域有重叠,若重叠则继续执行。
(2)搜索该节点区域维护的对象列表,将与搜索区域重叠的对象加入结果列表。
(2)如果该空间有被标记为划分,则对每个子区域,都递归地执行(1)。
实现
QuadTreeNode代码
数据结构
其中的Collider2D非自带,是自己写的,参考以往博文 https://www.cnblogs.com/JimmyZou/p/18296317
public class QuadTreeNode
{
public Vector2 center; // 位置
public float size; // 尺寸,此处设定每个节点的管辖区域都为正方形,因此只需设定边长
public List<Collider2D> objects; // 该节点维护的列表
public QuadTreeNode[] children; // 顺序 左上、左下、右下、右上
public bool isDivided => children != null; // 是否划分
private int depth; // 当前深度
}
插入操作
public void Insert(Collider2D obj)
{
// 如果物体不处于该区域中,则返回
if (!Overlaps(obj))
{
return;
}
// 以下两种情况会让对象存到自身维护的对象列表中 (1)如果压了子区域的分割线 (2)深度到达最深的一层,不能再往下划分了
// 存入自身维护的对象列表中
if (CrossSplitLine(obj) || depth == maxDepth)
{
if(!objects.Contains(obj))
objects.Add(obj);
}
// 存入子区域维护的对象列表中
else
{
// 如果未划分,且当前节点的层级未到最大划分层级,则划分
if (!isDivided && depth <= maxDepth)
{
SubDivide();
}
foreach (QuadTreeNode child in children)
{
child.Insert(obj);
}
}
}
public void SubDivide()
{
children = new QuadTreeNode[4];
float len = size / 2; // 子区域的边长
children[0] = new QuadTreeNode(center + new Vector2(-1, 1) * len / 2, len, maxDepth, depth + 1);
children[1] = new QuadTreeNode(center + new Vector2(-1, -1) * len / 2, len, maxDepth, depth + 1);
children[2] = new QuadTreeNode(center + new Vector2(1, -1) * len / 2, len, maxDepth, depth + 1);
children[3] = new QuadTreeNode(center + new Vector2(1, 1) * len / 2, len, maxDepth, depth + 1);
}
查询操作
public void QueryArea(Vector2 min, Vector2 max, List<Collider2D> result)
{
// 如果物体不处于该区域中,则返回
if (!Overlaps(min, max))
{
return;
}
// 首先查找当前层维护的对象上是否和该查询区域有重叠
foreach (Collider2D obj in objects)
{
var (objMin, objMax) = obj.GetAABB();
if (min.x <= objMax.x && max.x >= objMin.x &&
min.y <= objMax.y && max.y >= objMin.y)
{
result.Add(obj);
}
}
// 再从子区域上递归搜索
if (isDivided)
{
foreach(var child in children)
{
child.QueryArea(min, max, result);
}
}
}
压线及重叠判断
/// <summary>
/// 判断参数min和max围成的矩形区域是否和当前区域有重叠
/// </summary>
public bool Overlaps(Vector2 min, Vector2 max)
{
var (boundMin, boundMax) = (this.center - Vector2.one * this.size / 2, this.center + Vector2.one * this.size / 2);
if (min.x <= boundMax.x && max.x >= boundMin.x &&
min.y <= boundMax.y && max.y >= boundMin.y)
return true;
return false;
}
/// <summary>
/// 判断参数min和max围成的矩形区域是否和划分子区域的十字分割线中的任意一根有交叉
/// </summary>
public bool CrossSplitLine(Vector2 min, Vector2 max)
{
// AABB的x和y的任意一个轴上,如果跨过了0点,则代表压线
if ((min.x < center.x && max.x > center.x) || (min.y < center.y && max.y > center.y))
return true;
return false;
}
QuadTree代码
目的仅为了对QuadTreeNode根节点和相关操作进行包装。
public class QuadTree
{
public QuadTreeNode rootNode { get; private set; }
private int maxDepth; // 四叉树的最大深度
public QuadTree(Vector2 center, float size,int maxDepth = 5)
{
this.maxDepth = maxDepth;
rootNode = new QuadTreeNode(center, size, this.maxDepth, 0);
}
public void Insert(Collider2D obj)
{
rootNode.Insert(obj);
}
public Collider2D[] QueryArea(Vector2 min, Vector2 max)
{
List<Collider2D> result = new List<Collider2D>();
rootNode.QueryArea(min,max, result);
return result.ToArray();
}
}
QuadTreeBehaviour代码
QuadTreeBehaviour的目的只是为了通过组件方式进行一些初始化赋值操作,并通过Gizmos将四叉树的每层边界递归绘制出来,核心代码如下。
创建四叉树
void Start()
{
Stopwatch stopwatch = new Stopwatch();
stopwatch.Start();
// 创建一棵树
quadTree = new QuadTree(Vector2.zero, boundSize, maxDepth);
// 插入所有节点
for (int i = 0; i < colliders.Length; i++)
{
if (colliders[i].gameObject.activeSelf)
quadTree.Insert(colliders[i]);
}
stopwatch.Stop();
UnityEngine.Debug.Log("创建四叉树并插入所有对象的耗时为:" + stopwatch.ElapsedMilliseconds + " ms");
}
查询
private void Update()
{
if (openQueryTest)
{
// 获取鼠标位置
worldMousePosition = ScreenPointToWorldPoint(Input.mousePosition);
// 以鼠标位置作为中心点进行范围查询
queryResults = quadTree.QueryArea(worldMousePosition - queryAreaSize / 2, worldMousePosition + queryAreaSize / 2);
}
}
绘制查询区域
private void OnDrawGizmos()
{
// 绘制四叉树最外层边界
Gizmos.DrawWireCube(Vector3.zero, new Vector3(boundSize, boundSize, 0));
if (Application.isPlaying)
{
// 绘制内存占用情况
// DrawMemorySize();
// 绘制四叉树内部划分情况
DrawRecursively(quadTree.rootNode);
// 绘制查询区域内发生碰撞的物体
if (openQueryTest)
{
// 绘制查询区域
Gizmos.color = new Color(1, 0, 0, 0.1f);
Gizmos.DrawCube(worldMousePosition, queryAreaSize);
// 标亮每个查询结果
Gizmos.color = new Color(1, 0, 0, 0.8f);
foreach (var result in queryResults)
{
var (min, max) = result.GetAABB();
var (center, size) = ((min + max) / 2, max - min);
Gizmos.DrawCube(center, size);
}
}
}
}
public void DrawRecursively(QuadTreeNode node)
{
// 绘制当前节点的边界框
Gizmos.color = Color.white;
Gizmos.DrawWireCube(node.center, node.size * Vector2.one);
// 如果当前节点被划分为子节点,递归绘制子节点
if (node.isDivided)
{
foreach (var child in node.children)
{
DrawRecursively(child);
}
}
}
性能分析
内存消耗
通过Memory Profier监控下,维护着1500个对象引用的QuadTree共占有7.4KB的内存空间。
运行时长
插入时长
此处创建了一棵四叉树并将n=1500个对象(基本均匀散布)插入进其中,花费了25ms。
查询时长
此处通过创建一个4x3大小的查询区域,判断包含在其中的所有物体,并以红色标记显示。
查询过程中的时间开销主要来源于(1)确定查询区域覆盖了哪些四叉树区域 (2)从覆盖区域中获取所有对象。
-
对于(1): 假如查询区域较小,那么确定查询区域所覆盖的四叉树区域的时间为O(logn)。假如查询区域较大,极端情况下和整个四叉树一样大,则每个分支都需要进行重叠判断,复杂度为O(n)。
-
对于(2): 假如各个对象分布均匀,查询区域中只有常数量的个体,那时间复杂度为O(1)。假如各个对象较密集,全部都在和查询区域重叠的四叉树区域中了,那么所有的对象都要进行遍历,判断是否确实落入了查询区,时间复杂度为O(n)。
综合相加(1)和(2)之后,平均时间复杂度为O(logn),最坏时间复杂度为O(n)。
以下是实际的运行状况:
由于单次查询耗时本身较小,观察不出差别,此处在一帧里进行1500次相同的查询(此处取1500是因为刚好可以模拟1500个对象同时执行查找下的时间开销)。
查询位置分别取密集处和稀疏处,得到耗时分别约为170ms(单次0.15ms)和45ms(单次0.05ms)。可以看出秘籍位置相较于稀疏位置耗时更长。
密集处的查询耗时:
稀疏处的查询耗时:
暴力方式下,无论查询区域在何处,都需要对每个物体进行遍历,极其耗时,时间开销接近了1000ms,如下图。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 地球OL攻略 —— 某应届生求职总结
· 周边上新:园子的第一款马克杯温暖上架
· Open-Sora 2.0 重磅开源!
· 提示词工程——AI应用必不可少的技术
· .NET周刊【3月第1期 2025-03-02】