BSP(二叉空間分割)樹(做模型分割的時候碰到這個演算法了轉載一下做個筆記)

月夜風雨磊發表於2018-08-20

BSP(二叉空間分割)樹是另一種型別的空間分割技術,其已經在遊戲工業上應用了許多年(Doom是第一個使用BSP樹的商業遊戲)。儘管在今天BSP樹已經沒像過去那麼受歡迎了,但現在仍在廣泛地採用這項技術。

當你看一下BSP在碰撞檢測方面那極度乾淨漂亮和高速的效率,立刻能讓你眼前一亮。不但BSP樹在多邊形剪下方面表現出色,而且還能讓我們有效地自由運用world-object式的碰撞檢測。BSP樹的遍歷是使用BSP的一個基本技術。碰撞檢測本質上減少了樹的遍歷或搜尋。這種方法很有用因為它能在早期排除大量的多邊形,所以在最後我們僅僅是對少數面進行碰撞檢測。正如我前面所說的,用找出兩個物體間的分隔面的方法適合於判斷兩個物體是否相交。如果分隔面存在,就沒有發生碰撞。因此我們遞迴地遍歷world樹並判斷分割面是否和包圍球或包圍盒相交。我們還可以通過檢測每一個物體的多邊形來提高精確度。進行這種檢測最簡單的一個方法是測試看看物體的所有部分是否都在分割面的一側。這種運算真的很簡單,我們用迪卡爾平面等式 ax + by + cz + d = 0 去判斷點位於平面的哪一側。如果滿足等式,點在平面上;如果ax + by + cz + d > 0那麼點在平面的正面;如果ax + by + cz + d < 0點在平面的背面。

在碰撞沒發生的時候有一個重要的事情需要注意,就是一個物體(或它的包圍盒)必須在分割面的正面或背面。如果在平面的正面和背面都有頂點,說明物體與這個平面相交了。(以上載選自百度百科)

Bsp分割演算法簡述

Preview

BSP分割演算法也是有不少文章可以借鑑的,就我目前能掌握的資料來看,泛泛而談者大有人在,實際去作的時候卻總是抓瞎。知道是什麼永遠不如知道怎麼做,BSP分割是BSP分析的基礎,雖然它很簡單,但是,如果連簡單的都不會做,又怎麼能勝任複雜的工作呢?

趁這段時間有空,遂埋頭鑽研BSP,一週之後,分割和自動Portal生成均已解決,遂做此文,希望能對初學者有所幫助,亦希望能拋磚引玉,眾位高手能不吝賜教。

本文先就BSP中相對簡單的分割部分做一個簡單的介紹,自動Portal生成的資料正在整理,希望能儘快放出。

BSP的基本原理

試想我們生活的空間,肯定是由為數眾多的天花板、牆壁和地板組成,對於每一個“板”,都將空間分為“板前”和“板後”兩個部分。已知人的位置,就可根據人在“板前”還是在“板後”,知道人所能看到的物體的遮擋順序(e.g.如果人在板前,則板前的物體遮擋所有板後的物體)。

BSP者,原理很簡單:它試圖將所有的板(在BSP中叫做平面)組織成一棵樹,每個平面均將它所在的空間分割為前後兩個部分,這兩個部分又分別被另外的平面分割成更小的空間……直到最後,按照前面所說的演算法,確定每一個房間(在BSP中叫做葉子)相對於眼睛的遮擋順序。

這是一個非常標準的二分法,僅按照“前”和“後”兩個邏輯上的概念來切分空間,這使得它在以“房間”為單位組成的室內場景裡是不二之選。為什麼?請接著看:

在判斷遮擋順序的時候,BSP空間的演算法極為簡單:只需要從樹根開始,簡單判斷人的位置與所有平面的前後關係:前則正子樹(在平面“前”方的空間)在前,負子樹(在平面“後”方的空間)在後;後則正子樹在後,負子樹在前。以此遞迴到葉子(葉子總是一個房間),就可以確定人處於哪一個房間之中、其他房間的遮擋關係如何。

這個其實很簡單:因為所有的平面均將其所處的空間分為前後兩個部分,所以,每一個房間,均是由若干平面的“前”“後”來決定的,通過人與這些平面前後關係的判斷,自然而然就可以直接定位到所需的房間之中了。這就是BSP演算法的特別之處。

如圖:空間ABC由A、B、C三個獨立的房間組成,首先,分割平面1將空間分成了平面正向的A房間和平面負向的BC空間,BC空間被2緊接著分割為平面2正向的C房間和負向的B房間。注意這裡平面的方向一般由牆壁面向的方向而定。

如果有一個人處於C房間內,那麼如何判斷所有房間的遮擋順序呢?從樹根開始,由於人處於平面1的“後”面,所以,BC空間應該先於A房間(後:先負後正),然後,由於人處於分割平面2的“前”面,所以,C房間應該先於B房間(前:先正後負)。這樣,整個房間離人由近到遠的順序就可以確定了:C-B-A。僅需要通過兩次平面的前後判斷(總共六次乘法、兩次加法、兩次大小判斷),就可以確定空間的先後順序,演算法的威力可見一斑!

BSP分割的目標是將空間劃分為一個個葉凸體,也就是一個凸面體。一個個凸面體才有排序的可能,很難想象一個非凸面體在空間中如何排序。如圖左:從箭頭方向看過去,到底凹多面體A是在B的前面?還是B在凹多面體A的前面?而如果是右邊的,兩個凸多面體,情況就不一樣了,A和B方向的前後,根據視點的位置永遠是唯一的。這就是BSP的優勢,只需要知道視點的位置,空間所有凸體的位置順序都可以馬上確定,但如果是凹體,對不起,那就確定不了了,所以,BSP劃分空間結構化面的結果必然是一個個凸面體。

這裡面唯一想強調的一點是,如果您分析過Quake3的BSP格式,那麼您會發現過去有時候一個房間會被幾個柱子分割得亂七八糟,只是為了少渲染幾個面。現在大不必這麼興師動眾,一個房間就留外面的6個結構化面,柱子什麼的只算作細節Mesh,不參與分割,這樣產生的結果,與Portal篩選結合之後,效率未必就差。而且,結構更符合邏輯,在以後自動路點和路徑計算的時候,會有一些優勢。想想看,被一個很不規則的柱子(或箱子等其他物體)劃分得亂七八糟的空間,一個房間就有很多個葉子,到底哪些葉子是人能走到的?哪些葉子是人走不到的?哪些葉子需要在AI中被考慮?哪些葉子可以排除?一個不以邏輯構成的空間,必然在邏輯的處理上要處處碰壁。所以,最好還是一個葉子就是一個房間、或者一個走廊;柱子、箱子啊什麼的全都用細節Mesh,就可以了。

注意,BSP劃分出的凸體其實主要是為了後面BSP分析而進行的,而不是渲染。早先的時候硬體很糟糕,沒有Z緩衝,那時候省一個三角形比現在重要得多。現在?有時候寧可多畫一堆三角形也不會去浪費那個CPU資源進行三角形的逐個篩選。所以,儘量減少結構化面,使結構化面的房間組成凸體,但細節面把房間裝點成什麼樣,那就無所謂了,即便細節面將這個空間又變成了一個凹體,也是無所謂的,如圖:

由於是一個老演算法了,因此BSP分割演算法早已經不是什麼神祕的東西,這個演算法有很多例子,推薦《BSP技術詳解》(翻譯後的名稱如此),唯一的遺憾是這篇文章的虛擬碼需要花點心思。另外,《3D遊戲 卷2 動畫與高階實時渲染技術》所帶的FLY 3D引擎也有很完整的程式碼,雖然整個看下來比虛擬碼還難懂,但是每個函式基本上都還算清晰,也是一個難得的備選資料。
當然,可能大部分人還是傾向於去看Quake和HL2的程式碼。為了使自己加深印象,我所選擇的是自己從零開始,僅按照資料上的觀點和流程進行DIY,而沒有參考程式碼。因為經常參考著、參考著就“拿來主義”了,雖然開發效率保證了,但是記性不清,一旦擴充套件起來,基本抓瞎^_^b。所以這次狠狠心,決定享受一次DIY的樂趣。

準備工作:場景資料

進入工作狀態,第一個問題是場景資料的配置。BSP的難度一定程度上不是演算法本身帶來的,BSP演算法很簡單也很明確,並沒有太多複雜的東西在裡面。複雜的是大凡好的BSP都需要和編輯器結合起來,以進行Portal、Brush、Entity和Path Point諸如此類的定製,直接從3D Max匯出一個Mesh然後就進行分析,這個從實踐上限制太多、意義不大,所以,與其說BSP分割很難,倒不如說是BSP的編輯器難做。記得一本老書上曾經說過,BSP編輯器的程式碼是BSP分割演算法的10倍有餘,仔細想想,確實如此,而且只會有過之而無不及。

在實踐中,我採用了《3D遊戲》的方法,這個方法是,通過在3D Max中物體的名稱來區分一個物體的這些面是屬於“結構化面”(分割平面)、“細節面”(不參與分割的面)還是Entity。由於3D Max Script支援使用字首將一組物體放入一個Array中,所以,使用一個簡單而明確的字首是一個很好的思路,《3D遊戲》使用了*、&這些符號,而我則使用了S(Split)、D(Detail)、E(Entity)。例如SBox01說明這個Box01的所有面均是結構化面,要參與BSP分割和分析,而DSphere01則說明這個Sphere01在BSP的分割和分析中將會被忽略。這中間的主要工作集中在3DMAX Script的撰寫(或者外掛的撰寫),所以就不再多說了,對這個技術還比較生疏的,可以參考網上相關的內容。

從3DMAX中讀出來Object後,其所有的頂點和麵索引都已知了,將所有頂點組織成一個頂點表,所有的結構化三角形組織成一個結構化三角形表(這裡的三角形是指頂點索引),這個比較簡單,應該不是問題。

資料進入我們的程式,第一件事情就是要首先計算出所有的平面,因為不同的結構化面可能共用一個平面,所以,這裡先需要計算出所有的平面並在平面和結構化面中建立關係,以防止同一個平面被兩次以上使用,影響BSP二分邏輯的正確性。D3DX給出了專門的函式D3DXPlaneFromPoints,可以很方便地從一個三角形產生出一個平面來。一個新的平面算出來後,檢查一下這個平面是否已經生成過了,如果沒有,就算作一個新平面並記錄其ID,否則就要捨棄這個新平面,轉而採用原有平面的ID。直到最後,為所有的結構化三角形給出其對應的平面ID。這中間注意一下D3DX的平面公式是ax+by+cz+d=0,用的是+d,不是-d,在之後的計算中需要注意。

準備好頂點表、結構化三角形表和平面表之後,分割就可以正式開始了。相對於3DMax Script和外掛而言,BSP分割的演算法本身容易得讓人崩潰,不多說了,下面開始!

BSP分割

首先,自然是要先產生一個根節點,並把所有的頂點表、結構化三角形表和平面表一股腦塞進這個根節點中咯。

然後,分割的流程大抵如下:
1 遍歷當前節點的所有備選平面,尋找一個合適的分割平面。
2 如果找不到合適的分割平面,這個節點是一個葉子,Return。
3 如果找到了,Mark這個平面已經被使用過。
4 New兩個新節點,一個為正向節點,一個為負向節點,掛接到本節點下。
5 遍歷所有結構化面。
6 如果結構化面在分割平面的:
正向:將這個結構化面和結構化面所對應的平面放入到正向節點。
負向,放入到負向節點。
如果結構化面被分割平面分割,則分割此三角形,並將分割後的結果放入相應的子節點。
(注意,這一步當發現結構化面所對應的平面已經被Mark的時候,就只放結構化面,不放分割平面了,以防止同一個平面用於分割兩個以上空間,違反BSP空間二分邏輯的唯一性)
7 遍歷所有細節面。細節面的處理與結構化面類似,只不過這裡不用考慮到細節面對應的平面問題,更簡單。
8 遍歷完畢,由於所有的結構化三角形、平面和細節面已經轉移到兩個子節點中了,因此從本節點中解掉所有的結構化三角形、平面和細節面的引用。節點所需保留的資料只需要是分割平面和兩個下級節點的指標即可。
9 對兩個子節點,分別從1開始遞迴執行。

這樣,等一切結束的時候,就是一棵完整的BSP樹了,所有的節點中僅保留有節點的分割平面和兩個下級節點,而渲染嚴重相關的結構化三角形和細節面則全都在葉子裡。最後,只需要順根遞迴,將所有的節點組織成節點表就可以了。我在這裡分別是將節點組織成了節點表,將葉子組織成了葉子表。您也可以通過為節點加一個Is Leaf屬性來將它們統一放到一個節點表裡。

現在,面臨最主要的問題是,在所有的結構化面中,如何尋找一個分割平面?

首先,分割平面必須是對於凹多面體而言的,已經形成了凸多面體的空間就不必要分割了。對於一個凹體而言,分割平面必須在平面的正負方向均出現三角形。如此遞迴分割下去,就能保證將空間最終分割成大量凸多面體集合。如下圖左,1、4在平面的一方沒有出現三角形,應被捨棄,2、3均可以作為備選的分割平面:

分割平面的選取是一個比較“笨”的辦法,可偷懶的機會不多,只能是for each的判斷。對於每一個平面,算出一個用於判斷的值,在所有值中最大(或者最小,視演算法而定)的那個平面就是最佳分割平面。最簡單的,永遠只選取第一個結構化三角形的平面分割,但是這樣分割下來的空間會慘不忍睹。分割出來的結果最好是讓一棵樹平衡的那個做法。因為平衡二叉樹的操作比不平衡二叉樹要快,冗餘度要小很多。

計算出最優平衡二叉樹幾乎是不可能的,但在近似層面上保證二叉樹儘可能平衡的演算法很多,《3D遊戲》採用的是:
P=分割後處於正向的三角形數
N=分割後處於負向的三角形數
S=被從中切開的三角形數
Value = P – N + 8 × S

這個值最小的那個就是最好的平面。也就是說,正負向三角形數量最接近、且切開三角形最少的那個平面就是最好的分割平面。

如上圖右,7、3、5均可以作為分割平面,但是,非常明顯:3就比7和5要好得多,因為其正負方向的三角形數最接近,且沒有切割任何三角形。

這個演算法在實際使用上,並不一定能生成最優樹,但它簡單而且直觀。沒有最好的演算法,只有最適合的演算法,演算法的選擇不是唯一的,基本上應該根據空間的特點進行,所以這裡就不再多說了。總之,能儘量分割出平衡二叉樹的方法就是好的方法。

BSP分割完後,產生出來的節點表和葉子表,其中,節點表構成了BSP樹的樹幹,葉子表存有所有的結構化三角形和細節Mesh,將被用作之後分析的基本資料來源。而在所有的分析中,首先應該進行的就是Portal的分析,Portal分析完畢後,PVS等分析才有可能。而Portal和PVS,則是BSP空間分割最有魅力的兩個部分,在最新的商業引擎中,仍能看到他們的影子,而且,比起90年代初,只會有過之而無不及……

補充和校正

關於分割一個三角形

上篇文件寫完後,做了一個比較複雜的場景,進行分割後發現原演算法的一些問題,在此做一個補充。根據此補充,原文件將被刪掉重新修改完後再發,對各位讀者造成的不便,希望大家能夠見諒。

在上篇文章裡談到的分割演算法裡有關被分割面的處理,採取的是直接將被分割面正負都放的策略。當時認為這隻會對AABB的計算產生影響,所以也就堂而皇之這麼寫上去了。這個演算法雖然簡單,但是在之後的Portal處理時會面臨很多困難,這一點也是我開始沒有考慮到的。

在將場景變得複雜之後,這個問題就越發顯現出來:在有些葉子,將會僅包括若干被分割的共享三角形,且這些三角形根本無法構成封閉空間。然而,這些葉子卻被送入了Portal計算,最後出來的Portal非常詭異,甚至包括了在同一面上的若干個Portal。

用更多的思路更改Portal演算法,倒不如從根本上將空間分割得更為合理,也就是採取標準的做法:將被分割三角形分割開,分割為多個三角形,分別放入相應空間。

其實這個演算法很簡單,一個三角形如果被一個平面分割,直觀上看,有且只有兩種情況:一種是在正負各生成一個三角形;另一個是在一側有一個三角形,另一側有兩個三角形。直觀上說,無論哪種情況,關鍵演算法流程都是:

順序訪問原三角形的邊,設邊的第一個頂點是v0,第二個頂點是v1。
如果這個邊的兩個頂點均在平面一側,則兩個頂點算入平面相應一側的新多邊形。
如果有一個點在平面上,則這個點如果是這個邊的第一個頂點,應該在平面兩側的新多邊形中都要放。如果是第二個頂點,則需要判斷第一個頂點在平面的哪一側,並將之放入相應空間(只放一次)。可參考下圖(左)來進行理解。
如果這個邊被平面切割,則:首先算出來切割後的頂點vip,注意這裡需要根據頂點格式分割,法線、紋理座標均應分割。這時,同樣是判斷第一個頂點在平面哪一側,根據此,把v0、vip、v1按照相應順序組合,分別放到兩側的多邊形中(在這過程中,vip會兩側都放)。

這個演算法有幾個需要注意的地方:

首先,為了生成頂點順序與原三角形一致的三角形(即順時針三角形生成後仍是順時針,逆時針三角形生成後仍是逆時針),我們必須要按照相應的順序遍歷原三角形的邊:v0-v1、v1-v2、v2-v0,只有順序訪問原三角形的邊才能保證生成後的三角形的順序。如果一開始的順序就很詭異,那麼最後生成出來的三角形順序將很難保證,程式碼也會很不直觀。
第二,分割出來兩側的是多邊形而不是三角形,這需要分開判斷,如果多邊形的頂點數量是3,說明這一側生成的是一個三角形,那麼就好辦了,直接使用這個三角形即可。如果是4,說明是一個四邊形。如果設四邊形頂點順序是v0 v1 v2 v3那麼,組成這個四邊形的兩個三角形分別應該是v0-v1-v2和v0-v2-v3。具體的推導過程就不說了,如果覺得難於理解,可以參考下面的圖,就容易明白了。其中,OV是指原始三角形的三個頂點,V是分割後的這個四邊形的四個頂點,請注意順序。

第三,注意法線的切分,如果兩個頂點的法線方向正好相反(當然,這是特殊情況),那麼最後生成的新頂點的法線會是0!在這種情況下,法線需要單獨作一下處理,我的處理是將整個面的法線賦給這個頂點,當然,您也可能有更好的方式。
第四,在分割中會生成新的頂點和麵,所以最後BSP的頂點數和麵數經常會超過在模型原始資料裡的頂點數和麵數。但現在由於沒有被兩個葉子共同共享的三角形了,所以,一個葉子中的三角形可以統一建一張IB,一次渲染了,速度當然會比使用共享面要快。

切分演算法並不是唯一的,正如BSP分割的方式也並不唯一一樣,關鍵還是選擇對自己最容易掌握,最有利的演算法。

相關文章