空間劃分的數據結構(網格/四叉樹/八叉樹/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能渲染遠處本該看到的地圖塊。

相關文章
相關標籤/搜索