空間劃分的資料結構

KillerAery發表於2019-05-26

前言:
在遊戲程式中,空間劃分往往是非常重要的優化思想。
於是博主花了一些時間去整理了遊戲程式中常用的幾個空間劃分資料結構,並將它們大概列舉出來以供筆記。

網格

這個很容易理解,即一個多維陣列。平面/基於高度的空間使用二維網格陣列,而3D空間使用三維網格陣列。

Data girds2d[MAX_X][MAX_Y];//2D平面劃分網格,二維陣列
Data girds3d[MAX_X][MAX_Y][MAX_Z];//3D空間劃分網格,三維陣列

網格的應用

  • 基於網格劃分的遊戲世界

例如戰棋/棋類遊戲,Minecraft方塊遊戲等。


四叉樹/八叉樹

空間劃分的資料結構

四叉樹索引的基本思想是將地理空間遞迴劃分為不同層次的樹結構。
它將已知範圍的空間等分成四個相等的子空間,如此遞迴下去,直至樹的層次達到一定深度或者滿足某種要求後停止分割。

空間劃分的資料結構

//示例:一個四叉樹節點的簡單結構
struct QuadtreeNode {
  Data data;
  QuadtreeNode* children[2][2];
  int divide;  //表示這個區域的劃分長度
};
//示例:找到x,y位置對應的四叉樹節點
QuadTreeNode* findNode(int x,int y,QuadtreeNode * root){

  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 = root->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元素陣列),其構建方法與查詢方法也大同小異,不多描述。

四叉樹/八叉樹的應用

它們可以進行區域較大的劃分,然後可以對各種檢測演算法進行分割槽域的剪枝/過濾。
下面提幾個應用(實際應用面很廣):

  • 感知檢測

如圖所示,假如保證一個(圖中為綠色⚪)智慧體最遠不會感知到所在區域外的地方。
那麼通過四叉樹,可以快速過濾掉K區域外的紅色目標,只需考慮K區域內的紅色目標。
空間劃分的資料結構

  • 碰撞檢測

類似上面感知檢測。不同劃分割槽域保證不會碰撞的情況下,就能快速過濾與本物體不同區域的其他潛在物體碰撞。

  • 光線追蹤(Ray Tracing)過濾

光線追蹤渲染,可使用八叉樹來劃分3D空間區域,從而過濾掉大量不同區域。


BSP樹

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維座標點;
樹的每層都是對應一個劃分維度(取決於你定義第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的構建過程為:
一、迴圈依序取資料點的各維度來作為劃分維度。
二、取資料點在該維度的中值作為切分超平面,將中值左側的資料點掛在其左子樹,將中值右側的資料點掛在其右子樹。
三、遞迴處理其子樹,直至所有資料點掛載完畢。

//一種構建方法示例,此處虛擬碼省略了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樹的最近鄰查詢演算法:

一、首先記錄一個輸入點與節點的最短距離平方minDisSq,初始值為無窮大。

二、利用k-d樹,對其進行深度優先遍歷,每次遍歷節點的步驟:

  1. 先計算輸入點在第i維度值與當前節點的第i維度值之差的平方值sq1,並與minDisSq比較大小。
  2. 如果\((sq1 ≥ minDisSq)\),則證明當前節點劃分的另一邊區域(其中一個子樹)不可能有更近的點,所以可剪枝該區域,即只遍歷當前節點劃分的同區域(遍歷另一個子樹)。
  3. 如果\((sq1 < minDisSq)\),則證明當前節點劃分的兩個區域(兩個子樹)都可能有更近的點。此外還要計算輸入點和當前節點的距離平方值sq2,再與minDisSq比較,若更短則更新minDisSq。然後遍歷當前節點劃分的兩個區域(遍歷兩個子樹,先後順序應該是先遍歷同區域,再遍歷另一邊區域)。

三、最後得到一個minDisSq對應的節點。

//一個最近鄰目標查詢程式碼示例

//此處為了方便程式碼編寫,使用2個全域性變數記錄
float minDisSq = FLT_MAX;
KdTreeNode* gResult = nullptr;

//遞迴函式,呼叫該函式後,更新後的gResult即是結果。
void findNearest(Vector2 point, KdTreeNode* node) {
    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(logN)。

至於為什麼目標最好是靜態的,因為kd樹的構建往往非常耗時,如果動態則需要時時重新構建,所以更適合預先構建靜態目標的kd樹。

注:還有一種稱之為主席樹的動態更新方法,具體效率如何博主則沒過多深入研究。

參考


層次包圍盒樹

層次包圍盒樹,是一棵二叉樹,用來儲存包圍盒形狀。
它的根節點代表一個最大的包圍盒,往下2個子節點則代表2個子包圍盒。

此外為了統一化層次包圍盒樹的形狀,它只能儲存同一種包圍盒形狀。

計算機常用的包圍盒形狀有球/AABB/OBB/k-DOP,若不清楚這些概念可以自行搜尋瞭解。

下圖為層次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能渲染遠處本該看到的地圖塊。

相關文章