空间划分的数据结构(网格/四叉树/八叉树/BSP树/k-d树/BVH/自定义划分)

前言:
在游戏程序中,空间划分每每是很是重要的优化思想。
因而博主花了一些时间去整理了游戏程序中经常使用的几个空间划分数据结构,并将它们大概列举出来以供笔记。node

网格 (Grid)


这个很容易理解,即一个多维数组。平面/基于高度的空间使用二维网格数组,而3D空间使用三维网格数组。程序员

Data girds2d[MAX_X][MAX_Y];//2D平面划分网格,二维数组
Data girds3d[MAX_X][MAX_Y][MAX_Z];//3D空间划分网格,三维数组

网格的应用

  • 基于网格划分的游戏世界

例如战棋/棋类游戏,Minecraft方块游戏等。算法

四叉树/八叉树 (Quadtree/Octree)


四叉树索引的基本思想是将地理空间递归划分为不一样层次的树结构。
它将已知范围的空间等分红四个相等的子空间,如此递归下去,直至树的层次达到必定深度或者知足某种要求后中止分割。编程

//示例:一个四叉树节点的简单结构
struct QuadtreeNode {
  Data data;
  QuadtreeNode* children[2][2];
  int divide;  //表示这个区域的划分长度
};
//示例:找到x,y位置对应的四叉树节点
QuadTreeNode* findNode(int x,int y,QuadtreeNode * root){
  if(!root)return;

  QuadtreeNode* node = root;
  
  for(int i = 0; i < N && n; ++i){
    //经过diliver来将x,y概括为0或1的值,从而索引到对应的子节点。
    int divide = node->divide;
    int divideX = x / divide;
    int divideY = y / divide;
    
    QuadtreeNode* temp = node->children[divideX][divideY];
    if(!temp){break;}
    node = temp;
    
    //若是概括为1的值,还须要减去该划分长度,以便进一步划分
    x -= (divideX == 1 ? divide : 0);
    y -= (divideY == 1 ? divide : 0);
  }
  
  return node;
}

四叉树的结构在空间数据对象分布比较均匀时,具备比较高的空间数据插入和查询效率(复杂度O(logN))。数组

而八叉树的结构和四叉树基本相似,其拥有8个节点(三维2元素数组),其构建方法与查询方法也大同小异,很少描述。缓存

四叉树/八叉树的优化方案

减小子节点指针的跳转(指针跳转使CPU缓存不易命中,屡次跳转更是偏慢):
首先四叉树节点以数组形式存储,而后须要访问时直接经过位置进行一次运算获得一个索引值,从而直接访问该位置表明的数组元素。缺点是内存占用与一个满四叉树的内存无异。数据结构

四叉树/八叉树的应用

相比网格,四叉树/八叉树主要是多了层次,它们能够进行区域较大的划分,而后能够对各类检测算法进行分区域的剪枝/过滤。
下面提几个应用(实际应用面很广):编辑器

  • 感知检测

如图所示,假如保证一个(图中为绿色⚪)智能体最远不会感知到所在区域外的地方。
那么经过四叉树,能够快速过滤掉K区域外的红色目标,只需考虑K区域内的红色目标。
ide

  • 碰撞检测

相似上面感知检测。不一样划分区域保证不会碰撞的状况下,就能快速过滤与本物体不一样区域的其余潜在物体碰撞。

  • 光线追踪(Ray Tracing)过滤

光线追踪渲染,可以使用八叉树来划分3D空间区域,从而过滤掉大量不一样区域。

参考

  • 《游戏编程精粹2(Game Programming Gems 2)》 Mark A. Deloura [2003-12]

BSP树 (Binary Space Partitioning Tree)


BSP tree是一棵二叉树,其每一个节点表示一个有向超平面形状,其表明的平面将当前空间划分为前向和背向两个子空间,分别对应左儿子和右儿子。

(为了方便说明,下文的平面一概指超平面)

若是用一种特定的方式遍历,BSP树的几何内容能够从任何角度进行先后排序。

//大体的BSP tree节点结构
class BSPTreeNode {
    std::vector<Vector3> vertexs;  //多边形的顶点
    Data data;                     //数据
    BSPTreeNode* front;            //前向的节点
    BSPTreeNode* back;             //后向的节点
    //...
};

要构造一棵较平衡的BSP树,其实原理很简单:尽量每次划分出一个节点时,让其左子树节点数和右子树节点数相差很少。也就是说,用一个平面形状构造一个BSP树节点时,需知足它前方的多边形数和后方的多边形数之差 小于 必定阈值;若超过阈值则尝试用另外一个形状来构造。构造完一个节点则移除对应的一个平面形状。最后全部平面形状都被用于构造节点,组成了一棵BSP树。

麻烦在于当2个平面形状是相交时,它既能够在前方也能够在后方的状况。这时候就须要一个将该形状切割成两个子形状,从而能够一个添加在前方,一个添加在后方,避免冲突。

本文就很少描述具体实现了(犯懒),感兴趣可看下面的参考列表。

结论是:BSP树构造的最坏时间复杂度为O(N²logN),平均时间复杂度为O(N²)。

判断点在平面先后算法

平面的法向量为\((A,B,C)\),则平面方程为:
\(Ax+By+Cz+D = 0\)

将点\((x_0,y_0,z_0)\)代入方程,得
\(distance = Ax_0 + By_0 + Cz_0 + D\)

\(distance < 0\),则在平面背后;
\(distance = 0\),则在平面中;
\(distance > 0\),则在平面前方。

BSP树的应用

因为BSP树构造的平均时间复杂度为O(N²),所以其每每更适合针对静态物体进行离线构造。

  • 自动生成室内portal

大型室内场景游戏引擎基本离不开portal系统:

  1. portal系统可在运行时进行额外的视野剔除,过滤掉不少被遮挡的物体渲染,有效地优化室内渲染。
  2. portal系统还能够离线构造PVS(潜在可见集),计算出在某个划分区域潜在能够看到哪些其余区域,将这些数据存储成一个潜在可见集;在运行时根据该集合实时加载潜在可看到的区域。

可是对于关卡编辑师来讲,对每一个房间/大厅/走廊/门...手动放置每一个portal无疑是极大的工做量。因而有一种利用BSP树自动生成portal的作法,大体作法是:

  1. 首先BSP树节点数据应该为须要渲染的墙体/门/柱子等室内较大物体。
  2. 将BSP树节点连着的左节点视为一个儿子,右节点视为一个邻居。
  3. 全部相连的父子节点所表明的平面组成了一个凸多边形房间。
  4. 计算每一个相邻的房间之间相衔接的点,称为portal点。

建议结合看图理解,一个示例:

根据定义,在BSP树找到了3个凸多边形房间。

在各个相邻房间之间建立好portal点对(2个绿点,绿线表示portal平面):

基于portal系统运算获得的视野(进行了2次额外的视野剔除):

portal系统其实是很是复杂的,但很是有价值(良好优化的室内FPS游戏基本不会缺乏它)。因为其适合离线构造的特性,这种系统每每是编辑器程序员所须要使用,这里仅仅只能点下自动生成portal的皮毛,更具体的细节可看本节参考。

  • 渲染顺序优化

首先根据摄像机的位置,遍历BSP树找到并记录其位置相对应的叶节点,称之eyeNode,它将会是顺序遍历渲染的一个重要的停止条件。
因为eyeNode每每是在一些平面的前面,另外一些平面的后面,因此为了达到正确的从近到远的顺序,须要两次不一样方向的遍历。

对于没有深度缓存的老旧硬件,对BSP树从远到近渲染(从远处往摄像机位置)三角形图元,避免较远的三角形覆盖到较近的三角形上,从而到达正确的三角形图元渲染顺序,这也就是古老的画家算法。

注:这里的节点数据应该表明为须要渲染的三角形图元。
其顺序:
第一次遍历,左中右顺序,从根节点开始,直到eyeNode中止;
第二次遍历,右中左顺序,从根节点开始,直到eyeNode中止;

对于现代渲染硬件来讲,对BSP树近到远渲染(从摄像机位置往远处)物体,能够减小一些overdraw(即对像素的重复覆写)开销。

注:这里的节点数据应该表明为须要渲染的固定物体(诸如块岩石/柱子/固定的桌子椅子,这些物体每每能够用一些粗略平面表明之)。
其顺序:
第一次遍历,左中右遍历,从eyeNode开始,直到递归所有结束;
第二次遍历,右中左遍历,从eyeNode开始,直到递归所有结束;

参考

k-d树 (k-dimensional tree)


k-d树 是一棵二叉树,其每一个节点都表明一个k维坐标点;
树的每层都是对应一个划分维度(取决于你定义第i层是哪一个维度);
树的每一个节点表明一个超平面,该超平面垂直于当前划分维度的坐标轴,并在该维度上将空间划分为两部分,一部分在其左子树,另外一部分在其右子树;

即若当前节点的划分维度为i,其左子树上全部点在i维的值均小于当前值,右子树上全部点在i维的值均大于等于当前值,本定义对其任意子节点均成立。

实际上k-d树就是一种特殊形式的BSP树(轴对齐的BSP树)。

//一种实现方式示例:二维k-d树节点
class KdTreeNode{
  Vector2 position;         //位置
  Data data;                //点数据
  int dimension;            //当前所属层的维度
  KdTreeNode* children[2];  //两个子树
};

举例,一棵k-d树(k=2)的结构如图:

根据第一层划分维度为X,第二层为Y,第三层为X,
因此该k-d树(k=2)对应表明划分的空间,看起来应该是这样的:

k-d树的构建

基本定义有了,接下来问题就是如何构建k-d树。

此外一提,一棵平衡的k-d tree对最近邻搜索、空间搜索等应用场景并不是是最优的。

常规的k-d tree的构建过程为:
1、循环依序取数据点的各维度来做为划分维度。
2、取数据点在该维度的中值做为切分超平面,将中值左侧的数据点挂在其左子树,将中值右侧的数据点挂在其右子树。
3、递归处理其子树,直至全部数据点挂载完毕。

//一种构建方法示例,此处伪代码省略了Data部分
KdTreeNode* createKdTree(int dimension, std::vector<Vector2>& points, int beginIndex, int endIndex) {
    if(beginIndex >= endIndex) return nullptr;
  
    //先根据当前划分维度来排序[beginIndex,endIndex)区域的点
    if (dimension == 0) {
      std::sort(points.begin() + beginIndex, points.begin() + endIndex, 
      [](Vector2 & a, Vector2 & b) {return a.x < b.x; });
    }
    else if (dimension == 1) {
      std::sort(points.begin() + beginIndex, points.begin() + endIndex,
      [](Vector2 & a, Vector2 & b) {return a.y < b.y; });
    }
    //中值选择
    int midValueIndex = points.size() / 2;
    //以该中值建立一个划分节点
    KdTreeNode* node = new KdTreeNode(points[midValueIndex]);

    //递归构建子树
    //左子树为较小值,区间应该为[beginIndex,midValueIndex)
    node->children[0] = createKdTree(!dimension, points, beginIndex, midValueIndex);
    //左子树为较大值,区间应该为[midValueIndex+1,endIndex)
    node->children[1] = createKdTree(!dimension, points, midValueIndex + 1, endIndex);

    return node;
}

构建k-d树的两种优化角度:

  • 切分维度选择优化
    构建开始前,对比数据点在各维度的分布状况,数据点在某一维度坐标值的方差越大分布越分散,方差越小分布越集中。从方差大的维度开始切分能够取得很好的切分效果及平衡性。
  • 中值选择优化
    第一种,算法开始前,对原始数据点在全部维度进行一次排序,存储下来,而后在后续的中值选择中,无须每次都对其子集进行排序,提高了性能。
    第二种,从原始数据点中随机选择固定数目的点,而后对其进行排序,每次从这些样本点中取中值,来做为分割超平面。该方式在实践中被证实能够取得很好性能及很好的平衡性。

k-d树的应用

  • 最近邻静态目标查找

在编写游戏AI时,一个智能体查找一个最近静态目标(例如最近的房子/固定NPC/固定资源)是容易的,对全部单位一个个遍历检测最短平方距离便可(时间复杂度O(N))。当数百个单位(集群AI)都须要寻找最近的静态目标时,这时候可能比较适合使用基于k-d树的最近邻查找算法:

1、首先记录一个输入点与节点的最短距离平方minDisSq,初始值为无穷大。

2、利用k-d树,对其进行深度优先遍历,每次遍历节点的步骤:

  1. 先计算输入点在第i维度值与当前节点的第i维度值之差的平方值sq1,并与minDisSq比较大小。
  2. 若是\((sq1 ≥ minDisSq)\),则证实当前节点划分的另外一边区域(其中一个子树)不可能有更近的点,因此可剪枝该区域,即只遍历当前节点划分的同区域(遍历另外一个子树)。
  3. 若是\((sq1 < minDisSq)\),则证实当前节点划分的两个区域(两个子树)均可能有更近的点。此外还要计算输入点和当前节点的距离平方值sq2,再与minDisSq比较,若更短则更新minDisSq。而后遍历当前节点划分的两个区域(遍历两个子树,前后顺序应该是先遍历同区域,再遍历另外一边区域)。

3、最后获得一个minDisSq对应的节点。

//一个最近邻目标查找代码示例

//此处为了方便代码编写,使用2个全局变量记录
float minDisSq = FLT_MAX;
KdTreeNode* gResult = nullptr;

//递归函数,调用该函数后,更新后的gResult便是结果。
void findNearest(Vector2 point, KdTreeNode* node) {
    if(!node) return;

    float sq1;
    //计算输入点在第i维度值与当前节点的第i维度值之差的平方值sq1
    if (node->dimension == 0) {
      sq1 = (point.x - node->position.x)*(point.x - node->position.x);
    }
    else if (node->dimension == 1) {
      sq1 = (point.y - node->position.y)*(point.y - node->position.y);
    }
    
    //大于等于minDisSq,证实当前节点划分的另外一边区域(其中一个子树)不可能有更近的点
    if (sq1 >= minDisSq) {
        //遍历1个子树(同区域的)。
        findNearest(point,node->getSameDivideArea(point));
    }
    //小于minDisSq,证实当前节点划分的两个区域(两个子树)均可能有更近的点
    else {
        //计算输入点和当前节点的距离平方值sq2
        float sq2  = (point.x - node->position.x)*(point.x - node->position.x)
                    +(point.y - node->position.y)*(point.y - node->position.y);
        //再与minDisSq比较,若更短则更新minDisSq,并记录该点
        if (sq2 < minDisSq) {
            minDisSq = sq2;
            gResult = node;     
        }
    
        //遍历2个子树,先遍历同区域,后遍历另外一区域
        findNearest(point, node->getSameDivideArea(point));
        findNearest(point, node->getDifferntDivideArea(point));
    }
}

以下图例子,咱们想找到与点(3,5)最近的目标:

经过算法,咱们从绿色箭头顺序遍历,
并剪枝了一些不可能的子树,灰色部分便是剪枝部分:

k-d树剪枝了大量在较远区域的目标,效率提高地很好,其平均时间复杂度能够达到\(O(n^{1-\frac{1}{k}})\),k为维度。

至于为何目标最好是静态的,由于kd树的构建每每很是耗时,若是动态则须要时时从新构建,因此更适合预先构建静态目标的kd树。

注:还有一种称之为主席树的动态更新方法,具体效率如何博主则没过多深刻研究。

参考

层次包围盒树 (Bounding Volume Hierarchy Based On Tree)


层次包围盒简称BVH;而层次包围盒树是一棵二叉树,用来存储包围盒形状。
它的根节点表明一个最大的包围盒,往下2个子节点则表明2个子包围盒。

此外为了统一化层次包围盒树的形状,它只能存储同一种包围盒形状。

计算机的包围盒形状有球/AABB/OBB/k-DOP,若不清楚这些形状术语能够自行搜索了解。

经常使用的层次包围盒树有AABB层次包围盒树。

AABB层次包围盒树

下图为层次AABB包围盒树。把不一样形状粗略用AABB形状围起来看做一个AABB形状(为了统一化形状),而后才创建层次AABB包围盒树。

在物理引擎里,因为物理模拟,大部分形状都是会动态更新的,例如位移/旋转/伸缩都会改变形状。因而就又有一种支持动态更新的层次包围盒树,称之为动态层次包围盒树。它的算法核心大概:形状的位移/旋转/伸缩更新对应的叶节点,而后一级一级更新上面的节点,使它们的包围体包住子节点。

通常来讲这种数据结构经常使用于物理引擎。

层次包围盒树的应用

  • 碰撞检测

在Bullet、Havok等物理引擎的碰撞粗测阶段,每每使用一种叫作 动态层次AABB包围盒树(Dynamic Bounding Volume Hierarchy Based On AABB Tree) 的结构来存储动态的AABB形状。
而后经过该包围盒树的性质(不一样父包围盒一定不会碰撞),快速过滤大量不可能发生碰撞的形状对。

  • 射线检测/挑选几何体

射线检测从层次包围盒树自顶向下检测是否射线经过包围盒,若不经过则无需检测其子包围盒。
这种剪裁可以让每次射线检测平均只需检测O(logN)数量的形状。
经过一个点位置快速挑选该点的几何体也是相似的原理。

参考

自定义区域


一个自定义区域通常是一个凸多边形,而后可经过一些编辑器手动设置好其各顶点位置,最终手工划分出一块凸多边形区域。3D凸多面体通常不多用,即便在要求划分区域属于同一XOZ面不一样高度的3D世界里,考虑到性能,可能更适合用凸多边形+高度来划分区域。

相信我,能不用凹多边形就不用,由于许多程序算法均可以应用在凸多边形上,而相对应用于凹多边形上可能行不通或者得用更低效的算法。

为了达到自定义区域之间的无缝衔接,游戏程序还每每采用图(或者树)结构来存储这些自定义区域,表示它们之间的联系。

//区域
class Chunk{
  Data data;                      //区域数据
  std::vector<Vector2> vertexs;   //区域凸多边形顶点
  std::vector<Chunk*> neighbors;  //邻近区域
};

判断点是否在凸多边形区域算法

既然用到了凸多边形区域,那就顺便提一提如何判断点是否在凸多边形区域,并且也不是很难:

点对凸多边形每一个顶点之间创建一个线段2D向量,该向量与其对应的顶点的边进行叉乘,获得一个叉积值。
若每一个叉积值的符号都同样(都是正数/都是负数),则证实点在凸多边形内。
不然,则证实点再也不凸多边形内。

再举个例子:

如图,能够看到
\(sign((v4-p)×(v5-v4)) ≠ sign((v2-p)×(v3-v2))\)

所以可知点不在凸多边形内。

bool Chunk::inChunk(Vector2 p){
  int size = vertexs.size();
  for(int i = 0; i< size; ++i){
    Vector2 edge = vertex[(i+1)%size]-vertex[i];
    Vector2 vec = vertex[i] - p;
    //边都是逆时针方向,线段向量方向为指向凸多边形的顶点。
    //若点在凸多边形内,获得的叉积值应都为正数
    int result = cross(edge,vec);
    
    if(sign(result) == 0)return false;
  }
  return true;
}

显而易见的,该算法时间复杂度为O(|V|);V为凸多边形顶点数。

若让该算法进一步提高效率,可以让算法达到O(log|V|)的效率,大概思想是用叉积判断点在边的左右加二分查找。

不过考虑到凸多边形顶点数量通常不会不少(除非开发者丧心病狂的使用几十边形乃至上百千,这已是基本不可能的事了),就提一提吧。

自定义区域划分的应用

自定义区域是很是灵活的,每每能够应用于任何游戏,特别适合非规则世界的游戏。

  • 更灵活的渲染分区块渲染

典型须要灵活划分不规则区块的游戏莫过于赛车游戏,其赛道每每崎岖蜿蜒,因此其实潜在大量区域没必要渲染。但由于赛道布局的不规则,因此这些路段区域每每须要手工设置划分。

当汽车在相应的红线区域时,没必要渲染其余红线区域(或使用低耗渲染),由于每每汽车的视野基本都是往前看,狭隘的视野可观察到的地方实际上颇有限。

固然除了赛车游戏,还有许多其余游戏都须要用到这种划分,减小没必要要的渲染。

  • 地图载入

如图,先将关卡地图划分红①②③④地图块。
而后再自定义划分好Chunk A/B/C/D,而且设置好相应规则用于加载地图块:当玩家在Chunk A时,加载①;在Chunk B时加载①②;在Chunk C时加载②③;在Chunk D时加载③④。

这样能够实现一些基本的地图载入衔接,在相应的Chunk能渲染远处本该看到的地图块。

相关文章
相关标签/搜索