空间管理Space management:四叉树 & 八叉树

分治算法因其巧妙的算法思想能够将算法的时间复杂度降低到一个非常低的程度,例如广为流传的二分搜索算法将线性搜索时间复杂度O(n)降低到了O(log n),这是一个极其恐怖的性能提升。诸多的实际应用领域例如计算机图形学、计算机视觉都有着分治算法的身影。在这里我们将重点关注计算机图形学领域非常实用、好用、高效的分治算法——四叉树空间分割算法和八叉树空间分割算法,二分搜索算法本质上处理的是一维的数据,但在图形学领域我们通常面临的是二维或者三维的点和向量,而且在实际应用中这些数据的量都非常庞大(几百万个点的点云、高精度的网格模型等等),因此为了加速这些更高维数据的搜索,一些高效的数据结构算法被提出,其中四叉树和八叉树算法就是其中之一。(当然也有高维的二叉树,例如k-d树)

一、背景介绍

  我们先来看一下算法的应用背景,计算机图形学领域研究的主要内容就是关于二维三维空间的图形绘制、物理模拟、几何建模、电脑动画等等可视化课题,目前最为流行的模型表示方法就是采用三角形网格模型,即每个网格面采用一个三角形来表示,如下图1所示:

 

 

图1 三角网格模型

  这种表示方法就是采用了有限元的思想,利用很多个三角形去逼近一个曲面,三角形越多,网格精度越高,模型越接近目标物体。在一个大型的场景中,通常有很多个这样的物体模型,因此采用暴力的方法遍历每一个物体送入绘制管线、碰撞检测等等是非常不可行的,特别是对于游戏、虚拟现实等等实时性要求比较高的应用来说这是灾难性的做法,暴力搜索策略的碰撞检测将耗费大部分的时间。除了碰撞检测之外,还有射线与物体的求交运算,即发射一条射线,找到射线与物体相交的一点,这个在射击游戏中非常常见,此外在基于光线追踪的图形渲染技术中亦是如此。暴力、简单的做法就是将射线方程与场景中的每个三角形进行数学上的交点求取运算(其实就是解一个方程),遍历完所有的三角形,取一个最近的交点就是最终结果,算法复杂度为O(n)。这种方法不可取,复杂场景中的三角形数量几千万甚至上亿,在每一帧进行这样求交运算带来的后果就是帧率的急剧降低,用户看到的画面将非常卡顿,毫无流畅的游戏体验感。   事实上,可以很容易理解,射线并不会与场景中所有的三角形相交,暴力遍历的算法有99.9%99.9%的计算量都在做无用功,因为在所有的三角形中只有一个三角形才是我们要寻找的会相交的、距离最最近的那个三角形。注意到这些,学者们提出了一些基于分治的空间分割算法思想,将二维空间、三维空间做一个划分,排除点那些不可能相交的三角形,加速整个搜索过程。在众多的空间分割算法中,四叉树和八叉树的算法是其中的一种简单、高效、好用的空间分割算法,其中四叉树对应的是二维的空间划分,而八叉树对应的是三维的空间划分,算法的思想并不难理解,本质上属于二分搜索的高维扩展。 四叉树和八叉树就是2D和3D的“二分法”,搜索过程与二叉树搜索也类似,二叉树中是将数组排序后存入二叉树中,从而在查找中实现时间复杂度为log n。而四叉树/八叉树是按平面/空间范围划分有序节点,将所有点/面片/网格模型放入所属节点中,达到类似于排序的结果,进而在搜索时可以快速排除掉那些不符合条件的 点/面片/网格模型。 ## 二、四叉树分割算法   四叉树或四元树也被称为Q树(QuadTree)。四叉树广泛应用于图像处理、空间数据索引、2D中的快速碰撞检测、存储稀疏数据等,而八叉树(Octree)主要应用于3D图形处理。 #### 1、四叉树分割算法——原理   四叉树索引的基本思想是将地理空间递归划分为不同层次的树结构。它将已知范围的空间等分成四个相等的子空间,如此递归下去,直至树的层次达到一定深度或者满足某种要求(例如数据对象数量少于一定的阈值)后停止分割。四叉树的结构比较简单,并且当空间数据对象分布比较均匀时,具有比较高的空间数据插入和查询效率,因此四叉树是GIS中常用的空间索引之一。常规四叉树的结构如图所示。

 

 

图2 一颗构建好的四叉树

  这里划分空间通常是一个轴向包围盒,这个包围盒可以用它的最低点和最高点来表示。总的来说,四叉树的定义是:它的每个节点下至多可以有四个子节点,通常把一部分二维空间细分为四个象限或区域并把该区域里的相关信息存入到四叉树节点中。 四叉树的每一个节点代表一个矩形区域,每一个矩形区域又可划分为四个小矩形区域,这四个小矩形区域作为四个子节点所代表的矩形区域。   这里的四叉树只有叶子节点才存储数据对象(如点、三角面片、网格模型等等),内部节点不存储数据对象,因而访问数据对象都要根据内部节点走到叶子节点去访问。一般点是没有大小的,因此数据对象是点的时候没有必要考虑跨越了多个区域的情况。而如果数据对象是面片、网格模型等有大小的时,四叉树的构建就需要小心一点。如图2所示,数据对象是一个矩形,每个矩形有一定的大小,图中的22、44、88这三个数据对象跨越了两个区域,为了防止遗漏,在构建四叉树的时候最好把这些跨越了多个区域的数据对象均放入它所涉及到的叶子节点上。一般情况下叶子节点不会直接存储原始的数据对象,而是将原始的数据对象用一个线性表存储,然后四叉树中的叶子节点存储的是数据对象在线性表中的索引,这是为了防止程序代码耦合度过高。   说了这么多,接下来我们以构建烟花粒子系统的四叉树为例展开相关的算法介绍和实现。烟花粒子系统中的数据对象是粒子点云,即点集,我们为这个系统构建一颗四叉树,并将其可视化出来。首先定义四叉树中的一个节点对象,代码如下所示。一个节点对象可能是内部节点,也可能是外部节点。对于内部节点,它应该有四个子节点的指针;对于叶子节点,它应该有一个存储数据对象的表。对于每一个节点,都应该有一个包围盒指定当前节点所覆盖的矩形空间范围,我们用矩形空间范围的最小点和最大点来表示。

struct TreeNode
{
	//! 子节点
	TreeNode *children[4];
	//! 包围盒
	glm::vec2 bMin, bMax;
	//! 叶节点的物体列表
	std::vector<glm::vec2> objects;
	//! 是否是叶节点
	bool isLeaf;

	TreeNode() :
		isLeaf(false), bMin(glm::vec2(0.0f)), bMax(glm::vec2(0.0f))
	{
		children[0] = children[1] = children[2] = children[3] = nullptr;
	}

	TreeNode(glm::vec2 min, glm::vec2 max) :
		isLeaf(false), bMin(min), bMax(max)
	{
		children[0] = children[1] = children[2] = children[3] = nullptr;
	}
};

#### 2、四叉树分割算法——构建   从一颗空树开始,给定粒子系统中所有的粒子点云的线性表,我们采用自顶向下的方式构建一颗四叉树。从根节点往下划分,直到到达给定的树的深度或者当前节点覆盖的数据对象少于一定的数量时不再往下划分。根据数据对象数量、当前节点的深度,自顶向下构建四叉树的过程大致可以分成三类: - 如果当前数据对象数量为零,代表当前节点覆盖的区域不包含任何数据对象,则返回空指针,表示当前节点是一个不包含任何对象的空指针节点; - 如果当前数据对象不为零且小于一定的数量,亦或者当前节点的深度达到最大深度,则不再往下划分,将当前的节点构建为叶子节点,并将数据对象添加到叶子节点的存储表中; - 出去上面的两种情况,剩下的情况就是当前节点为内部节点的情况,对于每一个内部节点,需要往下划分四个区域节点,我们根据当前区域的范围分割成上、下、左、右四个子区域,根据数据对象的位置将数据对象表分成四类,递归调用构建函数,返回子节点的指针,最后再返回当前内部节点的指针。   以上的步骤递归嵌套地执行,即可完成自顶向下的四叉树构建。详细的代码实现如下所示,这个函数的输入是当前节点的深度,当前节点的矩形范围以及数据对象列表,返回构建的节点指针。

TreeNode * QuadTree::recursiveBuild(unsigned int depth, glm::vec2 min, glm::vec2 max,
	const std::vector<glm::vec2>& objects)
{
	//! if there is no object at all, just return nullptr.
	if (objects.empty())
		return nullptr;

	//! if the number of objects is less than 10 or reach the maxDepth,
	//! just create the node as leaf and return it.
	if (objects.size() < 4 || depth == mMaxDepth)
	{
		TreeNode *cur = new TreeNode(min, max);
		for (auto &point : objects)
		{
			if (isContain(point, min, max))
				cur->objects.push_back(point);
		}
		cur->isLeaf = true;
		return cur;
	}

	//! otherwise just subdivied into four sub nodes.
	glm::vec2 center = (min + max) * 0.5f;
	float length = std::max(max.x - min.x, max.y - min.y);

	// ---------
	// | 3 | 2 |
	// ---------
	// | 0 | 1 |
	// ---------
	glm::vec2 subMin[4];
	glm::vec2 subMax[4];

	//! get the four subnodes' region.
	subMin[0] = min;
	subMax[0] = center;
	subMin[1] = center - glm::vec2(0.0f, length / 2);
	subMax[1] = center + glm::vec2(length / 2, 0.0f);
	subMin[2] = center;
	subMax[2] = max;
	subMin[3] = min + glm::vec2(0.0f, length / 2);
	subMax[3] = center + glm::vec2(0.0f, length / 2);

	//! subdivide the objects into four classes according to their positions.
	std::vector<glm::vec2> classes[4];
	for (auto &point : objects)
	{s
		if (isContain(point, subMin[0], subMax[0]))
			classes[0].push_back(point);
		else if (isContain(point, subMin[1], subMax[1]))
			classes[1].push_back(point);
		else if (isContain(point, subMin[2], subMax[2]))
			classes[2].push_back(point);
		else if (isContain(point, subMin[3], subMax[3]))
			classes[3].push_back(point);
	}

	//! allocate memory for current node.
	TreeNode *cur = new TreeNode(min, max);
	cur->children[0] = recursiveBuild(depth + 1, subMin[0], subMax[0], classes[0]);
	cur->children[1] = recursiveBuild(depth + 1, subMin[1], subMax[1], classes[1]);
	cur->children[2] = recursiveBuild(depth + 1, subMin[2], subMax[2], classes[2]);
	cur->children[3] = recursiveBuild(depth + 1, subMin[3], subMax[3], classes[3]);

	return cur;
}

 这里需要提一点的是上面代码中的isContain函数,它输入一个点和一个矩形包围盒范围,判断这个点是否在这个矩形包围盒之内,这个判断很简单,不再赘述。我们来看一下这种自顶向下构建方法的时间复杂度。四叉树的**每一层**都会处理O(N)个数据对象,若数据分布的比较均匀,则树高为O(log4N),因此构建的时间复杂度为O(Nlog4N),乍一看比暴力方法的时间复杂度还要高,但我们对每一个数据对象没有做很复杂的数据运算(如射线求交、碰撞检测等等),而且构建只需一次,对于那些检索密集型的应用来说非常划算。 #### 3、四叉树分割算法——销毁   我们采用四叉链表结构作为四叉树的实现方式,涉及到大量的动态指针,在销毁时需要手动释放这些指针占用堆空间。销毁四叉树并不难,直接采用**后序遍历**方法即可,后序遍历即先销毁子节点,将子节点指针置空,然后再销毁父节点,如此递归下去。

void QuadTree::recursiveDestory(TreeNode *node)
{
	if (node == nullptr)
		return;

	recursiveDestory(node->children[0]);
	recursiveDestory(node->children[1]);
	recursiveDestory(node->children[2]);
	recursiveDestory(node->children[3]);

	delete node;
	node = nullptr;
}

#### 4、四叉树分割算法——插入   除了前面提到的给定数据对象列表直接自顶向下构建整颗四叉树,还有一种构建方法是动态插入法。即给一个数据对象,将其插入到四叉树的一个叶子节点上,可以称为逐个插入法。逐个插入法就是要找到输入的数据对象所在的四叉树叶子节点,然后将该数据对象添加到该叶子节点的数据对象列表上。插入过程要靠的情况稍微多一点,如果插入的叶子节点的列表长度超过了一定数量,则应该将当前的叶子节点分裂,这时它不再是叶子节点而是内部节点了,递归插入到该内部节点的叶子节点中。总的来说,插入过程分成以下几种情况: - 若输入的数据对象不在当前节点的覆盖范围之内,直接停止插入过程; - 如果走到了四叉树的最大深度,则一定不会再分裂,直接将该数据对象添加到节点的数据对象列表当中; - 如果当前节点是叶子节点,将数据对象添加到该节点的数据对象列表中。然后若数据对象表列长度超过了一定数量,则需要将该叶子节点分裂,往下递归划分,将数据对象列表分散到子节点中,自己不再是叶子节点; - 如果当前节点是内部节点,则需要确定当前的数据对象落到它的四个子节点中的哪一个,然后递归调用插入过程。   插入过程也不是很复杂,理清了思路就好,实现的代码如下所示,代码的输入是深度、当前节点指针、当前节点覆盖的范围以及要插入的数据对象:

bool QuadTree::recursiveInsert(unsigned int depth, TreeNode * node,
                               glm::vec2 min, glm::vec2 max, glm::vec2 object)
{
	if (!isContain(object, min, max))
		return false;

	glm::vec2 center = (max + min) * 0.5f;
	float length = std::max(max.x - min.x, max.y - min.y);
	//! get the four sub-nodes' region.
	glm::vec2 subMin[4];
	glm::vec2 subMax[4];
	subMin[0] = min;
	subMax[0] = center;
	subMin[1] = center - glm::vec2(0.0f, length / 2);
	subMax[1] = center + glm::vec2(length / 2, 0.0f);
	subMin[2] = center;
	subMax[2] = max;
	subMin[3] = min + glm::vec2(0.0f, length / 2);
	subMax[3] = center + glm::vec2(0.0f, length / 2);

	//! reach the max depth.
	if (depth == mMaxDepth)
	{
		node->objects.push_back(object);
		return true;
	}

	if (node->isLeaf)
	{
		node->objects.push_back(object);
		if (node->objects.size() > 4)
		{
			//! 超过四个就分裂,自己不再是叶子节点
			node->children[0] = new TreeNode(subMin[0], subMax[0]);
			node->children[1] = new TreeNode(subMin[1], subMax[1]);
			node->children[2] = new TreeNode(subMin[2], subMax[2]);
			node->children[3] = new TreeNode(subMin[3], subMax[3]);
			node->isLeaf = false;
			node->children[0]->isLeaf = true;
			node->children[1]->isLeaf = true;
			node->children[2]->isLeaf = true;
			node->children[3]->isLeaf = true;

			for (auto &point : node->objects)
			{
				if (isContain(point, subMin[0], subMax[0]))
					node->children[0]->objects.push_back(point);
				else if (isContain(point, subMin[1], subMax[1]))
					node->children[1]->objects.push_back(point);
				else if (isContain(point, subMin[2], subMax[2]))
					node->children[2]->objects.push_back(point);
				else if (isContain(point, subMin[3], subMax[3]))
					node->children[3]->objects.push_back(point);
			}
			std::vector<glm::vec2>().swap(node->objects);
		}
		return true;
	}

	//! 若为内部节点,往下深入搜索
	if (isContain(object, subMin[0], subMax[0]))
		return recursiveInsert(depth + 1, node->children[0], subMin[0], subMax[0], object);
	if (isContain(object, subMin[1], subMax[1]))
		return recursiveInsert(depth + 1, node->children[1], subMin[1], subMax[1], object);
	if (isContain(object, subMin[2], subMax[2]))
		return recursiveInsert(depth + 1, node->children[2], subMin[2], subMax[2], object);
	if (isContain(object, subMin[3], subMax[3]))
		return recursiveInsert(depth + 1, node->children[3], subMin[3], subMax[3], object);

	return false;
}

 插入一个数据对象的时间复杂就是四叉树的高度,理想情况下为O(log4N)。

#### 5、四叉树分割算法——删除   有插入就有删除,给定一个数据对象,我们要找到包含这个数据对象的节点,并将其从数据对象列表中删除。与插入的情况相反,删除对象的操作可能导致当前节点的数据对象列表为空,此时需要将当前节点从整颗四叉树中删除,因为它不再包含任何有效的数据。叶子节点的删除可能导致父节点的删除(全部子节点变为空指针),如此递归下去,因此需要仔细斟酌整个删除的过程。总的来说,删除过程需要考虑的情况有如下几种: - 当前节点为空或者当前节点覆盖的范围不包含输入的数据对象,直接终止删除过程; - 当前节点为叶子节点或者走到最大四叉树深度了,遍历当前节点的数据对象列表,找到需要删除对象的数组下标,如果没有则不执行删除操作,否则将其从数组中移除。如果移除一个对象之后的数据列表为空,则需要将其删除,返回一个标志告诉父节点将其删除; - 当前节点为内部节点,找到包含该数据对象的子节点,递归调用删除程序,如果程序返回一个删除标志,则释放该子节点的内存。如果内部节点的四个子节点指针均为空指针,则告诉该内部节点的父节点将其删除,如果递归下去。   将数据对象从数组中删除一个小小的技巧就是将数组尾部的内容覆盖到该数据对象所在的位置,然后将尾部的数据删除即可,避免数据的大规模移动。

bool QuadTree::recursiveRemove(unsigned int depth, TreeNode * node, glm::vec2 min, glm::vec2 max, glm::vec2 object)
{
	if (node == nullptr)
		return false;

	//! 确保在当前节点的范围之内
	if (!isContain(object, min, max))
		return false;

	//! 达到最大深度或者走到叶子节点了.
	if (depth == mMaxDepth || node->isLeaf)
	{
		//! 找到要删除元素的位置
		int index = -1;
		for (size_t i = 0; i < node->objects.size(); ++i)
		{
			if ((std::pow(node->objects[i].x - object.x, 2) + std::pow(node->objects[i].y - object.y, 2)) < 0.001f)
			{
				index = i;
				break;
			}
		}
		if (index != -1)
		{
			//! 与最后一个元素交换,方便删除
			node->objects[index] = node->objects.back();
			node->objects.pop_back();
		}
		//! 如果叶子节点空了,则告诉父节点需要将其删除掉.
		if (node->objects.empty())
			return true;
		return false;
	}

	//! 非叶子节点
	if (recursiveRemove(depth + 1, node->children[0], node->children[0]->bMin, node->children[0]->bMax, object))
	{
		delete node->children[0];
		node->children[0] = nullptr;
	}
	else if (recursiveRemove(depth + 1, node->children[1], node->children[1]->bMin, node->children[1]->bMax, object))
	{
		delete node->children[1];
		node->children[1] = nullptr;
		return false;
	}
	else if (recursiveRemove(depth + 1, node->children[2], node->children[2]->bMin, node->children[2]->bMax, object))
	{
		delete node->children[2];
		node->children[2] = nullptr;
	}
	else if (recursiveRemove(depth + 1, node->children[3], node->children[3]->bMin, node->children[3]->bMax, object))
	{
		delete node->children[3];
		node->children[3] = nullptr;
	}

	//! 父节点的全部子节点为空,自己也将被删除了
	if (node->children[0] == nullptr && node->children[1] == nullptr 
		&& node->children[2] == nullptr && node->children[3] == nullptr)
	{
		return true;
	}

	return false;
}

#### 6、四叉树分割算法——访问   最后,为了将四叉树的空间划分结果可视化出来,需要对整颗四叉树做一个遍历。树形数据结构遍历都是一些很基础的基本功,这里不再赘述,每遍历到一个节点,将该节点的矩形区域范围绘制出来。

void QuadTree::recursiveTraverse(TreeNode *node, glm::vec2 min, glm::vec2 max, std::vector<glm::vec2>& lines)
{
	glm::vec2 center = (max + min) * 0.5f;
	float length = std::max(max.x - min.x, max.y - min.y);
	glm::vec2 corners[4];
	corners[0] = min;
	corners[1] = min + glm::vec2(length, 0.0f);
	corners[2] = max;
	corners[3] = min + glm::vec2(0.0f, length);

	//! get the bounding box to draw.
	lines.push_back(corners[0]);
	lines.push_back(corners[1]);

	lines.push_back(corners[1]);
	lines.push_back(corners[2]);

	lines.push_back(corners[2]);
	lines.push_back(corners[3]);

	lines.push_back(corners[3]);
	lines.push_back(corners[0]);

	if (node == nullptr || node->isLeaf)
		return;

	//! get the four sub-nodes' region.
	glm::vec2 subMin[4];
	glm::vec2 subMax[4];
	subMin[0] = min;
	subMax[0] = center;
	subMin[1] = center - glm::vec2(0.0f, length / 2);
	subMax[1] = center + glm::vec2(length / 2, 0.0f);
	subMin[2] = center;
	subMax[2] = max;
	subMin[3] = min + glm::vec2(0.0f, length / 2);
	subMax[3] = center + glm::vec2(0.0f, length / 2);

	recursiveTraverse(node->children[0], subMin[0], subMax[0], lines);
	recursiveTraverse(node->children[1], subMin[1], subMax[1], lines);
	recursiveTraverse(node->children[2], subMin[2], subMax[2], lines);
	recursiveTraverse(node->children[3], subMin[3], subMax[3], lines);
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值