空間划分的數據結構(四叉樹/八叉樹/BVH樹/BSP樹/k-d樹)


前言:
在游戲程序中,空間划分往往是非常重要的優化思想,可以應用於場景管理、渲染、物理、游戲邏輯等方面。
因此,博主將游戲程序中常用的幾個空間划分的數據結構整理出這篇筆記,也會持續更新下去,有錯誤或有未及之處望指出。

四叉樹/八叉樹 (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緩存不易命中,多次跳轉更是偏慢。
解決方案使首先四叉樹節點以數組形式存儲,然后需要訪問時直接通過位置進行一次運算得到一個索引值,從而直接訪問該位置代表的數組元素。
缺點是內存占用與一個滿四叉樹的內存無異。

松散四叉樹/八叉樹:減少邊界問題

四叉樹/八叉樹的一個問題是,物體有可能在邊界處來回,從而導致物體總是在切換節點,從而不得不更新四叉樹/八叉樹。

而松散四叉樹/八叉樹正是解決這種邊界問題的一種方式:
首先它定義一個節點有入口邊界(inner boundary),出口邊界(outerboundary)。
那么如何判定一個物體現在在哪個節點呢?

  1. 若物體還沒添加進四叉樹/八叉樹,則檢測現在位於哪個節點的入口邊界內;
  2. 若物體先前已經存在於某個節點,則先檢測現在是否越出該節點的出口邊界,若越出再檢測位於哪個節點的入口邊界內。

在非松散的四叉樹/八叉樹中,入口邊界和出口邊界是一樣的。
而松散四叉樹/八叉樹的松散,是指出口邊界比入口邊界要稍微寬些(各節點的出口邊界也會發生部分重疊,松散比較符合這種描述),從而使節點不容易越過出口邊界,減少了物體切換節點的次數。

隨之而來一個問題就是,如何定義出口邊界的長度。
因為太短會退化成正常四叉樹/八叉樹,太長又可能會導致節點存儲冗余的物體。
而在一篇關於松散四叉樹/八叉樹的論文里,實驗表明出口邊界長度為入口邊界2倍時可以表現得很好。

四叉樹/八叉樹的應用

相比網格,四叉樹/八叉樹主要是多了層次,它們可以進行區域較大的划分,然后可以對各種檢測算法進行分區域的剪枝/過濾。
下面提幾個應用(實際應用面很廣):

  • 場景管理

特別適合大規模的廣闊室外場景管理。
一般來說如果游戲場景是基於地形的(甚至沒有高度)(如城市、平原、2D場景),那么適合用四叉樹來管理。
而如果游戲場景在高度軸上也有大量物體需要管理(如太空、高山),那么適合用八叉樹來管理。

  • 感知檢測

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

  • 碰撞檢測

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

  • 光線追蹤(Ray Tracing)過濾

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

參考

層次包圍盒樹 (Bounding Volume Hierarchy Based On Tree)


層次包圍盒樹(BVH樹)是一棵多叉樹,用來存儲包圍盒形狀。
它的根節點代表一個最大的包圍盒,其多個子節點則代表多個子包圍盒。

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

計算機的包圍盒形狀有球體/AABB/OBB/k-DOP,若不清楚這些形狀術語可以自行搜索了解。

常用的層次包圍盒樹有AABB層次包圍盒樹和球體樹。

AABB層次包圍盒樹

下圖為層次AABB包圍盒樹。把不同形狀粗略用AABB形狀圍起來看作一個AABB形狀(為了統一化形狀),然后才建立層次AABB包圍盒樹。

在物理引擎里,由於物理模擬,大部分形狀都是會動態更新的,例如位移/旋轉都會改變形狀。於是就又有一種支持動態更新的層次包圍盒樹,稱之為動態層次包圍盒樹。它的算法核心大概:形狀的位移/旋轉/伸縮更新對應的葉節點,然后一級一級更新上面的節點,使它們的包圍體包住子節點。

一般來說這種數據結構常用於物理引擎。

球體樹

球體是最容易計算的一類包圍盒,而且球體樹構造速度可以很快,因此球體樹可被用作粗略松散但快速的空間划分結構。

快速構造松散球體樹的步驟(以三角形物體為例):

  1. 計算出包圍所有三角邊頂點的最小球體包圍盒,作為根節點。
  2. 以球心為坐標系原點,其坐標系X軸划分出在該X軸左右的三角形,並將這些分別放入左子節點、右子節點中。
  3. 重復步驟1、2。
  4. 最后得到一棵球體樹。

在步驟2中,還可以按X軸,Y軸,Z軸的順序輪流划分,即第一次步驟2划分用X軸,第二次步驟2划分用Y軸...

這樣生成的球體樹是松散粗略的,但是其平衡效果並不差,且最重要的是它的構造時間復雜度只有O(NlogN)。

層次包圍盒樹的應用

  • 碰撞檢測

在Bullet、Havok等物理引擎的碰撞粗測階段,使用一種叫做 動態層次AABB包圍盒樹(Dynamic Bounding Volume Hierarchy Based On AABB Tree) 的結構來存儲動態的AABB形狀。
然后通過該包圍盒樹的性質(不同父包圍盒必定不會碰撞),快速過濾大量不可能發生碰撞的形狀對。

  • 射線檢測/挑選幾何體

射線檢測從層次包圍盒樹自頂向下檢測是否射線通過包圍盒,若不通過則無需檢測其子包圍盒。
這種剪裁可讓每次射線檢測平均只需檢測O(logN)數量的形狀。
通過一個點位置快速挑選該點的幾何體也是類似的原理。

  • 視錐剔除

對BVH樹進行中序遍歷的視錐測試,如果一個節點所代表的包圍盒不在視錐范圍內,那么其所有子節點所代表的包圍盒都不會在視錐范圍內,則可以跳過測試其子節點。在這個遍歷過程中,通過測試的節點所代表的幾何體才可以發送渲染命令。

  • 輔助BSP樹構建

在BSP樹的構建中,利用球體樹輔助,可以將復雜度從O(Nlog²N)下降為O(NlogN)的復雜度。

參考

BSP樹 (Binary Space Partitioning Tree)


BSP tree是一棵二叉樹,中文譯名為二維空間分割樹,在游戲工業算是老功臣了,第一次被應用用是在1993年的商業游戲《DOOM》上,可是隨時渲染硬件的進步,基於BSP樹的渲染慢慢淘汰。但是即使在今天,BSP仍是在其他分支(引擎編輯器)不可或缺的一個重要數據結構。

BSP tree在3D空間下其每個節點表示一個平面,其代表的平面將當前空間划分為前向和背向兩個子空間,分別對應左兒子和右兒子。

2D空間下,BSP樹每個節點則表示一條邊,也可以將2D空間划分成前后兩部分。

//BSP tree節點結構示例
class BSPTreeNode {
	Plane plane;				  //平面
	BSPTreeNode* front;           //前向的節點
	BSPTreeNode* back;            //后向的節點
	//Data data;                  //數據
};

BSP樹的構建

3D空間下要構造一棵較平衡的BSP樹,則需要盡可能每次划分出一個節點時,讓其左子樹節點數和右子樹節點數相差不多:

  1. 在一個平面形狀集合里,用其中一個平面構造一個BSP樹節點時,需滿足它前方的平面形狀數和后方的平面形狀數之差 小於 一定閾值;若超過閾值則嘗試用下一個形狀來構造。

一個麻煩的問題是當2個平面形狀是相交時,即出現平面形狀既可以在前方也可以在后方的情況。這時候就需要一個將該形狀切割成兩個子形狀,從而可以一個添加在前方,一個添加在后方,避免沖突。

  1. 構造完一個節點則移除對應的平面,該節點前面的平面形狀和后面的平面形狀則作為兩個子平面形狀集合。
  2. 對這兩個子集合以重復步驟1、2繼續構造出兩個子節點,並作為本節點的左右兒子。
  3. 最后所有平面形狀都被用於構造節點,組成了一棵BSP樹。

由於需要進行N次划分,每次划分后,要在子集合里一個個挑選合適的平面(需要logN次遍歷),為了評定合適又需要與子集合里所有其它形狀比較前后位置(需要logN次比較),因此可以知道BSP樹構造的平均時間復雜度為O(Nlog²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(Nlog²N),因此其往往更適合針對靜態物體進行離線構造(預處理)。但在每次對關卡進行細微的改動時,設計師可能需要等待幾分鍾,這時間雖然不影響程序運行效率,但拖延了開發效率。一個比較好的辦法就是快速構造一棵粗略松散的球體樹,借此結構更快的構造BSP樹。

  1. 我們可以先對所有平面形狀構造一棵球體樹,同時需要每個節點額外記錄所擁有的形狀(它和它所有子包圍盒所代表的形狀)數量,整個過程時間復雜度為O(NlogN)。

    如何快速構造球體樹,在BVH的球體樹部分已經說明。

  2. 然后我們按照正常構造BSP樹的方法,每次划分選擇一個平面形狀,但是判定它前方形狀數和后方形狀數時不判斷一個個形狀在平面前后,而是判斷球體包圍盒在平面前后。
  3. 只要一個球體包圍盒完全在該平面前面或后面,那么它和它所有子包圍盒代表的形狀必定在平面前面或后面;若球體包圍盒與平面相交,則需要分解成它所有子球體包圍盒,再將這些球體包圍盒與平面比較前后。
  4. 由於球體包圍盒節點記錄了形狀數量,所以容易判斷球體和平面前后關系后,得到選擇某個平面時前后的形狀數。
  5. 最后我們構造出了一棵BSP樹。

此方法雖然生成的是一棵粗略、可能不是最平衡的BSP樹,但是只需要O(nlogn)的時間復雜度,每次對關卡做出改動時,其用時可能只有幾秒,這對設計者來說是相當理想的等待時間了。

BSP樹的應用

  • 自動生成室內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的皮毛,更具體的細節可看本節參考。

  • 自動生成導航網格

導航網格(Nav Mesh)是一種表示凸多邊形的節點,目前主流游戲的游戲AI尋路中最常用的節點種類。通過用導航網格,A*尋路所要搜索的節點數量大大減少且變得靈活。

因此我們可以預先計算可移動地形和靜態障礙,得到一個不規則的大地圖形狀(可能有凹處也可能有中空處),然后以該形狀的所有邊來構造一棵BSP樹來分割得到若干個凸多邊形房間,而這些分割出來的每個凸多邊形都是一個導航網格。

  • 構造CSG(Constructive Soild Geometry)幾何體

幾何體編輯是一個常見的編輯器功能,設計者往往需要通過基本圖形(立方體、球體、圓柱體、圓錐等)來進行並集、交集或差集,從而得到一個復合的幾何體,也就是所謂的CSG幾何體。

而BSP樹可以很好的處理和表示這些CSG幾何體,UE4引擎中的幾何體編輯就是采用BSP方式。

例如兩個三角形(圖左)可以通過BSP方式並集成為CSG幾何體(圖右)。

  • 渲染順序優化(不實用)

首先根據攝像機的位置,遍歷BSP樹找到並記錄其位置相對應的葉節點,稱之eyeNode,它將會是順序遍歷渲染的一個重要的中止條件。
由於eyeNode往往是在一些平面的前面,另一些平面的后面,所以為了達到正確的從近到遠的順序,需要兩次不同方向的遍歷。

在至少二十多年前,老舊硬件是沒有深度緩存,程序員使用BSP樹從遠到近渲染(從遠處到攝像機位置)三角形圖元,避免較遠的三角形覆蓋到較近的三角形上,從而到達正確的三角形圖元渲染順序,這也就是古老的畫家算法。

從遠處到eyeNode處的遍歷順序:
第一次遍歷,左中右順序,從根節點開始,直到eyeNode停止;
第二次遍歷,右中左順序,從根節點開始,直到eyeNode停止。

該BSP樹節點代表的數據應該是一個三角形(渲染的基本圖元),因為恰好三角形也是個平面形狀,因此該BSP樹節點代表的平面也就是其數據本身。

而今天對於現代渲染硬件來說,雖然對BSP樹近到遠渲染(從攝像機位置到遠處)物體可以減少overdraw(即對像素的重復覆寫)開銷,但是並不實用,花費昂貴的CPU代價換來少量GPU優化。

參考

k-d樹 (k-dimensional tree)


簡略化了kd樹部分。因為網上比較多kd樹的資料,這里就不必要再過多描述了。

k-d樹 是一棵二叉樹,其每個節點都代表一個k維坐標點;
樹的每層都是對應一個划分維度(取決於你定義第i層是哪個維度);
樹的每個節點代表一個超平面,該超平面垂直於當前划分維度的坐標軸,並在該維度上將空間划分為兩部分,一部分在其左子樹,另一部分在其右子樹。

實際上k-d樹就是一種特殊形式的BSP樹(軸對齊的BSP樹)。

//一種實現方式示例:二維k-d樹節點
class KdTreeNode{
  Vector2 position;         //位置
  int dimension;            //當前所屬層的維度
  KdTreeNode* children[2];  //兩個子樹
  //Data data;              //數據
};

舉例,一棵k-d樹(k=2)的結構如圖:

根據第一層划分維度為X,第二層為Y,第三層為X,
所以該k-d樹(k=2)對應代表划分的空間,看起來應該是這樣的:

k-d樹的應用

  • 最近鄰靜態目標查找

    在編寫游戲AI時,一個智能體查找一個最近靜態目標(例如最近的房子/固定NPC/固定資源)是容易的,對所有單位一個個遍歷檢測最短平方距離即可(時間復雜度O(N))。當數百個單位(集群AI)都需要尋找最近的靜態目標時,這時候可能比較適合使用基於k-d樹的最近鄰查找算法。

如下圖例子,我們想找到與點(3,5)最近的目標:

通過最近鄰查找算法,我們從綠色箭頭順序遍歷,
並剪枝了一些不可能的子樹,灰色部分即是剪枝部分:

k-d樹剪枝了大量在較遠區域的目標,效率提升地很好,其平均時間復雜度可以達到\(O(n^{1-\frac{1}{k}})\),k為維度。

至於為什么目標應該是靜態的,因為kd樹的構建往往非常耗時,如果目標是動態的則需要時時重新構建,所以更適合預先構建靜態目標的kd樹。
還有一種稱之為主席樹的動態更新方法,是ACMer的常用方法,具體效率如何博主則沒過多深入研究。

參考

自定義區域


一個自定義區域一般是一個凸多邊形,然后可通過一些編輯器手動設置好其各頂點位置,最終手工划分出一塊凸多邊形區域。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能渲染遠處本該看到的地圖塊。

結語


總的來說,游戲開發最常用的空間數據結構是四叉/八叉樹和BVH樹,而BSP樹基本上只能應用於編輯器上,k-d樹則只能用在特殊問題應用場景。

簡單整理了如下表格:

數據結構 適用情形 n的數量級 構造所需時間 是否可以動態更新 占用空間
四叉樹 場景管理(基於地形或不含高度)、渲染 物體數量 O(n*logn) 大(取決於區域大小和物體數量)
八叉樹 場景管理、渲染 物體數量 O(n*logn) 大(取決於區域大小和物體數量)
BVH樹 任何情形(包括物理、渲染) 物體數量 O(n*logn) 中(取決於物體數量)
BSP樹 編輯器、復雜室內場景 平面數量 O(n*log²n) 大(取決於平面數量)
k-d樹 需要頻繁查找最近靜態目標 物體數量 O(k*n*logn) 中(取決於物體數量)

一件有趣的事是我在查閱資料時,發現國內某篇2013年關於ABT樹的期刊論文內容和2005年的《Game Program Gems 5》里關於忽略緩存的ABT樹的內容如出一轍,再查了查作者,相同的內容換個標題又發成了另一篇期刊論文,再往下查作者的其他論文更是各種從Game Program Gems里直接搬出。

更新1:移除了網格(Grid)的章節,因為只是簡單的多維數組,所以精簡一下篇幅,其他內容部分修改。

更新2:更新了松散四/八叉樹、球體樹和快速構造BSP樹方法,其他內容部分修改。

更新3:更新了BSP樹部分,簡略化了kd樹。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM