Featured image of post 游戏引擎开发实践(空间加速结构)

游戏引擎开发实践(空间加速结构)

基础理论

均匀网格划分

把整个场景 AABB 均匀划分成 Nx × Ny × Nz 的三维网格,每个 cell 里存放与其相交的 primitive(如三角形)列表

image-20260220201611359

但是实际场景中Mesh很容易是按簇分布的,此时如果击中左上角这个cell,会导致大量的相交计算

image-20260220201812614

最容易的解决办法是提高grid的分辨率

image-20260220201901372

四叉树/八叉树

这种方法是从顶向下划分,Root表示整个场景,先切4刀,检查每个区域是否Mesh数量小于阈值了,如果不小于,继续划分

如下图中区域1就需要继续划分,而234就不需要了

image-20260220202236945

区域1中Mesh还是很多,继续划分

image-20260220202435529

继续划分新的区域1

image-20260220202509648

上述流程假定一个情况:每个三角形都能刚好划分到不同的区域,但实际上一个三角形可能分布在不同的区域内,所以每个涉及的区域都需要存储这个三角形,如下图

image-20260220202717111

松散八叉树

扩大子节点的范围,这样可以保证这个结点是完全包含五角星

假设一条射线从上到下进行击中判断,正好落在左上角和右上角两个结点的中心偏右一点,那左图(如果只在左边存储五角星)只有右边结点会加入检测队列,导致五角星漏掉了,那五角星就会被误判,右边就能正常把左边结点加入判断流程

img

线性八叉树

img

img

TODO:具体使用流程还不太清除

Morton Code

将多维数据映射到一维,同时保留数据点的局部性

空间中相近的坐标,编码后的数值也相近

img

下图展示计算一个XYZ坐标的莫顿编码

img

如果把空间中XYZ从0-10都编码并排序后连起来会发现,一维编码表示的空间坐标,在3D空间中有了局部连续性

img

BSP树

递归的用一个超平面去划分场景为两半,划分可以是任意的

image-20260220203015962

继续对AB两个区域进行划分

image-20260220203052069

与四叉树类似,当一个区域内的Mesh数量小于阈值后停止分割

image-20260220203153046

K-d Tree

是BSP树的一种特殊形式,横平竖直地进行分割,而不是随意分割

image-20260220203337299

BVH

上述方法都是自顶向下对空间进行划分,看Mesh落在哪个区域,而BVH是从底向上,合并多个Mesh为一个结点,直到合并场景中所有结点

一个关键区别是:BVH运行划分重叠,所以mesh可以安心得只落在一个结点,不会出现一个Mesh落在两个子空间,需要额外处理的情况

构建BVH的关键在于,如果划分左右区间使得左右两个结点的AABB重叠最小

BVH在GPU是bottomUp来构建的

img

这里注意:以前理解的 BVH构建是自底向上是不全面的,分CPU和GPU两种情况,如下图文字所示

image-20260220204855591

下面是TopDown的流程,Root表示所有三角形组成的AABB,每次都把大的AABB分成两个小的AABB,每个AABB表示一部分三角形组成的AABB

截屏2026-02-21 12.38.45

TopDown流程的问题是,怎么去划分呢?

可以选择沿着一条坐标轴进行,或者某个方向

划分可以使用空间的中心或者Mesh的中心

image-20260221151955944

按照空间来划分流程如下,首先选择最长轴,找到终点,分析每个Mesh的中心在这个中点的左侧还是右侧,从而划分两个部分,计算两部分的AABB,这样就划分好了

image-20260221152226463

如何与BVH进行求交运算呢,用一个递归代码就能完成

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
HitInfo TraverseBVH(Node* node, Ray ray) {
    if (!IntersectAABB(ray, node->aabb)) return HitInfo::miss();  // 剪枝
    
    if (node->isLeaf) {
        return IntersectTriangles(ray, node->triangles); // 真正的遍历求交
    } else {
        HitInfo hitLeft = TraverseBVH(node->left, ray);
        HitInfo hitRight = TraverseBVH(node->right, ray);
        return hitLeft.hitDistance < hitRight.hitDistance ? hitLeft : hitRight;
    }
}

从下图可以理解坐标中点划分并不是一种很好的方式(左图),AABB重叠了,右图是比较好的方式

img

BVH的SAH(表面积启发式的结点划分方法)

定义数学模型:

  1. 对一个Mesh求交的代价为t(x),那一批Mesh不分区情况下的总代价为:image-20260222131841560
  2. 如果将Mesh分为两区,光线击中某个区域的概率为p(x),此时的代价为(ttrav是表示遍历树状结构的代价):image-20260222131936958
  3. 结合BVH来看,AB分别表示划分后两个AABB的表面积,C是父结点的表面积,ab表示划分后各个区域的Mesh数量image-20260222132021797

从理论上理解:SAH目的是减少求交次数,首先Mesh越多,求交次数就越多,那Mesh所在的区域被击中的概率越大,进入区域去遍历这些Mesh的概率就越大,AABB的表面积越大,选择当前区域的概率越大,所以这两个因素相乘就是代价

八叉树实践

总体上就是递归构建,基本思路不难,估计难得是适配实际情况

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
#pragma once
#include "collision.h"
/*
	八叉树(目前这个代码跑不了,只是伪代码):
	构建: 把所有物体分配给Root,如果Root的物体数量大于MAX_LEAF_OBJECT_COUNT,就创建它的8个孩子,并且把所有物体按照位置分配给孩子(如果没法精确分配给某个孩子,就还是存在Root上),再遍历8个孩子,如果某个孩子物体数量大于MAX_LEAF_OBJECT_COUNT,继续递归构建
	添加:判断物体是否包含在Root,包含的话,就判断8个孩子是否完美包含它,如果包含,就递归给孩子去添加,如果都不包含。就存储在当前Root
	更新物体位置:判断是否还在同一个结点,如果不在就删除后重写插入,或者有一些论文去查询邻近结点

	松散八叉树:


	八叉树的用途:(加速遍历Mesh的判断,如果对某个结点的判断失败了,那这个结点以下的Mesh的判断可以完全跳过)
	视锥裁剪:用视锥体和Root进行相交判断,如果AABB完全在视锥外,直接剔除Root(剪枝),如果视锥完全包含当前AABB,直接添加当前Root下所有Mesh,如果只是相交,递归检查8个孩子,递归到孩子结点,如果还相交,就添加到可见物体数组
*/
namespace GameEngine {

#define MAX_LEAF_OBJECT_COUNT 4

	template<class T>
	class OctreeNode {
	public:
		OctreeNode(BoundingBox& Box) {
			AABB = Box;
		}
		std::vector<T> Objects;
		BoundingBox AABB;
		std::array<OctreeNode<T>*, 8> children{nullptr};


		bool IsStrictContain(T& Object) {
			return AABB.IsContains(Object.box);
		}
	};

	template<class T>
	class Octree
	{
	public:
		Octree(std::vector<T>& Objects, BoundingBox& Box) {
			Root = std::make_shared<OctreeNode<T>>(Box);
			CreateChildNode(Root);
			GenOctree(Root, Objects);
		}

		void CreateChildNode(std::shared_ptr<OctreeNode<T>> Root)
		{
			// TODO: 八个子空间生成,规划每个子空间的范围
			// Root->children[0] = new OctreeNode({ xxxx});
		}

		void GenOctree(std::shared_ptr<OctreeNode<T>> Root,std::vector<T> &Objects) {
			// 1. 每个物体划分到当前Root的8个子空间或Root空间中
			for (T& obj : Objects) {
				bool isPushed = false;
				for (OctreeNode<T>* CurSpace : Root->children) {
					if(CurSpace->AABB.IsContains(obj.aabb))
					{
						CurSpace->Objects.push_back(obj);
						isPushed = true;
					}
				}
				if (!isPushed)	// 没有一个子空间能够完全包住物体,放在父空间中
				{
					Root->Objects.push_back(obj);
				}
				isPushed = false;
			}

			// 2. 判断子空间是否需要继续划分
			for (OctreeNode<T>* CurSpace : Root->children)
			{
				if (CurSpace->Objects.size() > MAX_LEAF_OBJECT_COUNT)
				{
					CreateChildNode(CurSpace);
					GenOctree(CurSpace,CurSpace->Objects);
					CurSpace->Objects.clear(); // TODO:如果有物体存在Root上,这行代码就把他删了。。。。
				}
			}
		}
		

		void AddObject(T& Object) {
			AddObjectInner(Object, Root);
		}
		bool AddObjectInner(T& Object, std::shared_ptr<OctreeNode<T>> Root) {
			if (!Root) return false;
			if (!Root->AABB.IsContains(Object.aabb)) {
				return false;
			}
			bool isHandled = false;
			for (OctreeNode<T>* CurSpace : Root->children) {
				if (CurSpace && CurSpace->AABB.IsContains(obj.aabb))
				{
					// 说明子结点插入失败,交给当前结点处理
					isHandled = AddObjectInner(object, CurSpace);
				}
			}
			if (!isHandled) {
				Root->Objects.push_back(object);
			}
			return true;
		}


	private:
		std::shared_ptr<OctreeNode<T>> Root = nullptr;
	};
}

BVH + SAH实践(TODO)

CPU(自顶向下)

CPU上构建BVH主要是TopDown的,SAH用来划分区间

为了减少SAH的计算量,在最长轴上均匀分成若干桶,来减少计算,最终划分只会出现在桶内

img

image-20260222143138524

另外插入新结点时插入到哪个叶子结点也是根据SAH,如下图插入L的Cost = 新增的11结点的表面积 + 沿路向上的父结点表面积变化的和

img

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
#pragma once
#include "collision.h"
/*
	BVH:(TODO:目前是伪代码,SAH未完成)
	构建方式: 计算所有Mesh的AABB在哪个轴上最宽,在这个轴上对Mesh进行排序,根据某种划分算法把Mesh数组分成两部分,分别递归构建子树,直到叶子结点
	添加Mesh:首先插入一定发生在叶子结点,依据SAH,判断依哪一个节点的插入会有“最小表面积”的变化,具体计算公式看Blog
*/
namespace GameEngine 
{
	template<typename T>
	class BVHNode {
	public:
		BVHNode* left = nullptr;
		BVHNode* right = nullptr;
		BVHNode* parent = nullptr;
		std::vector<T> objects;
		BoundingBox AABB;
	};


	template<typename T>
	class BVH
	{
	public:
		BVHNode<T>* BuildBVHInner(std::vector<T>&objects, BVHNode<T>* Root) {
			// 1. 计算Root的AABB
			for (auto& object : objects) {
				Root->AABB.Merge(object.aabb);
			}

			// 创建Root
			if(!Root) Root = new BVHNode<T>();
			if (objects.size() <= 1) {
                Root->objects = objects;
                return Root;
			}
			// 2. 利用SAH算法,对Root进行划分
			int lefeMaxIndex = SAHSplit(Root, objects,int left, int right);

			// 3. 递归构建左右子树
			BVHNode<T>* left = BuildBVHInner(objects.begin() + left, objects.begin() + lefeMaxIndex, Root);
            BVHNode<T>* right = BuildBVHInner(objects.begin() + left + lefeMaxIndex, objects.begin() + right, Root);
			Root->left = left;
            Root->right = right;
            left->parent = right->parent = Root;
            return Root;
		}


		// SAH算法,返回划分后left的最大Index
		int SAHSplit(BVHNode<T>* node, std::vector<T>& objects, int left, int right) {

		}
		BVHNode<T>* Root = nullptr;
	};


}

GPU(自底向上,线性BVH)

以二维坐标和三维坐标为例,对于每一个点计算其对应的莫顿码,然后利用莫顿码对其进行排序,再将排序后的点按照顺序连接起来就会得到如下图所示的Z形曲线z-order curve

img

构建莫顿码就是把多维数组成的坐标的每个维度交错排布

image-20260222150035803

比如二维平面上[0-3]范围内的点,全部编码为莫顿编码后,排序连线如下

img

主要观察一点,用最高位0或1就可以把区域划分为上下两部分!

img

只看第二位,又可以左右划分,这样就划分 成了四个区域

img

所以莫顿编码 第一位为1,第二位为0的区域一定在左上角!

img

对于三角形图元,编码它的方式为 xyzmin表示整个场景的最小点,lwh是场景的宽度

image-20260222152312130

按这种方式计算后P就代表这个三角形在AABB盒中的位置,接下来对P进行莫顿编码

image-20260222152700576

构建BVH时,按照上边方法生成所有图元的编码,然后进行基数排序

这里基数排序回忆一下

image-20260222153236406

排序完成后,再按照最长公共前缀来进行切分

切分就是按照当前位是0还是1(其实不是看一位,而是最长公共前缀,比如前几个都是00),同样是0的说明在空间上是接近的

img

这种方式仍然是自顶向下的,下面介绍从底向上的GPU的构建办法

首先第一点:一个二叉树,叶子结点有N个时,非叶子结点一定有N-1个

img

Licensed under CC BY-NC-SA 4.0
📚 文章数: 72 ✍️ 总字数: 245.55K