背景
最近在實現一個 3D 的沙盒類遊戲,基本的功能就是在一個 3D 平面裡,進行建築物的搭建,可以在場景內新增或者編輯建築物,然後平面記憶體在一個人物模型,他可以穿梭行走於建築物之間。
在實現人物的行走功能的時候,我們很自然地想到取終點和起點兩點的座標,然後連成直線進行行走即可,在沒有建築物的時候這個想法確實很好,可是一旦在地圖中出現建築物之後就會發現:忒喵的人物穿模啦!
至此,尋找避障方案之旅開始了。
使用 Babylon.js 自帶的避障功能
由於我們的專案是使用 Babylon.js 框架來進行開發的,因此本著“能不自己寫堅決不自己寫”的原則,我們的第一個想法就是使用 Babylon.js 自帶的避障功能。
介紹
簡單介紹一下 Babylon.js 中自帶的避障功能,它屬於框架的一種擴充功能,需要結合 RecastJS 依賴進行使用。
利用 RecastJS 進行導航網格(Navigation Mesh)的生成。然後再通過 RecastJS 中的 Crowd Agents 模組進行自動的尋路和避障。
可能看到這裡的你會有一個疑問,什麼是導航網格,這裡引用一段我在其他部落格中看到的定義和介紹:“導航網格(Navigation Mesh),也俗稱行走面,是一種用於在複雜空間中導航尋路、標記哪些地方可行走的多邊形網格資料結構。”
一個導航網格是由許多的凸多邊形組成的,大白話地講就是將地圖切割成 N 份,每份都是一個凸多邊形,我們稱一個多邊形為一個 Poly。
然後當人物在導航網格中行走時,會判斷起點和終點是否在同一個 Poly 中,如果是的話,則直接將起點和終點連成直線,該直線即為人物的行走路線;否則就需要使用相應的尋路演算法進行路徑的計算,計算出人物需要經過哪些 Poly,再利用這些 Poly 算出具體路徑。
應用
在知道 Babylon.js 自帶了這麼一個功能的時候,當時我的內心是狂喜的,於是乎照著官網示例對著鍵盤一頓亂按後,重新整理頁面,新增一個建築,點選地圖,期待著人物模型自動地繞開建築物,結果:
What happened?
經驗告訴我,應該是導航網格沒有正常生成導致的,幸好,RecastJS 提供了多樣化的引數讓我可以去調整導航網格的生成規則。
於是乎,經過了幾乎一整天的引數調整,最後發現是我太天真了,結果根本沒有發生什麼變化。只有當新增的障礙物體積很小很小的時候,人物的避障功能才能成功生效,當障礙物的體積稍微大一點點,人物就會直直地穿過建築到達終點。
當時我的想法是:行吧,既然調參也沒有用,那我去看看 RecastJS 的原始碼,看看它是如何生成導航網格的,以及是什麼導致它導航網格生成不正常的。
所以,我轉戰到了 github。不搜不知道,一搜嚇一跳,發現這個 RecastJS 原來前身是一個 C 語言開發的庫,後來不知道被誰轉成了 Javascript 版本發到了 npm 上,也就是說,我們看不到 Recast 的 Javascript 版本原始碼。
額,好吧,使用 Babylon.js 自帶的避障功能這條路算是徹底堵死了。
自建導航網格和搜尋演算法
既然自帶的功能沒用成,而且在 Babylon.js 的社群生態中尚未有更好的解決方案,那麼就只能自力更生了,打造一套適用於當前場景的導航網格和搜尋演算法。
我們先來理一下整體的思路和流程:
- 獲取地圖資料,生成合適的導航網格
- 獲取人物行走的起點和終點
- 利用搜尋演算法在導航網格中找到起點到終點的最短路徑
- 操作人物按照最短路徑進行行動
選擇合適的搜尋演算法
先來講講搜尋演算法,說起尋路的搜尋演算法,最先讓人想到的應該是大名鼎鼎的廣度優先搜尋演算法。
廣度優先搜尋
廣度優先搜尋演算法是一種很常用的尋路演算法,被廣泛地應用在計算機的各種場景,比如 Windows 的畫板塗鴉功能,就是用廣度優先演算法實現的。
我們可以將整個地圖簡單地拆分成 N 個 1X1 的正方形格子,廣度優先演算法的基本原理很簡單,就是從起點格子出發,每次可以朝上下左右進行移動。下圖中的綠色標記點是起點,而紅色標記點是終點。
每一輪探索完畢之後,會標記這一輪探索過的方塊為邊界(Frontier),也就是下圖中的綠色正方形格子。
然後演算法會從這些邊界方塊開始,逐一繼續下一輪的探索,直到在探索過程中找到了終點方塊後,演算法才會停止探索。
在每次探索時,我們需要順勢記錄路徑的來向,也就是形成一個類似連結串列的結構,在到達終點後,我們便可以根據這個來向的記錄找到對應的最短路徑。
另外,每一輪所探索的路徑格子在下一輪探索開始前要對其進行標記,讓演算法知道這個格子已經被遍歷過了,之後的探索過程中如果遇到了被標記的格子,將直接跳過不會納入後續的探索輪次。下圖中灰色的格子即為被標記不再探索的格子。
其實廣度優先演算法實現起來很簡單,應用場景也很廣,但是它也有很明顯的缺點,那就是它很蠢,最壞情況下需要遍歷整張地圖才能找到目標點,所以很容易因為移動次數過多導致出現效能問題。
A-Star 演算法
為了解決廣度優先演算法的效能問題,於是乎我們引入了啟發式搜尋 A-Star 演算法,與廣度優先演算法有所不同的是,我們在每一輪搜尋的時候,不會去探索所有的邊界方塊,而是會選擇當前代價(cost)最低的方向進行探索,因此它是具有一定的方向性的,它前進的方向取決於當前邊界方塊裡最低代價方塊所在的位置。
代價分成兩部分,一部分是當前路程代價 ,或者叫做當前代價(f-cost),它表示你當前已走過的路徑數量,比如當前格子需要走三步才能到達,則當前代價為 3。
另一部分代價是預估代價(g-cost),表示從當前方塊到終點方塊大概需要多少步,由於它叫“預估”代價,因此它並不是一個精確的數值,這個估計值主要是用於指導演算法去優先搜尋更有希望的路徑。
這裡介紹兩種常用的預估代價:
- 尤拉距離(Euler Distance):當前格子到終點的直線距離,用數學公式來表示的話就是 Math.sqrt((x1 - x2)^2 + (y1 - y2)^2)
- 曼哈頓距離(Manhattan Distance):指當前格子和終點兩點在豎直方向和水平方向上的距離總和,用數學公式來表示就是 |x1 - x2| + |y1 - y2|,曼哈頓距離的計算不需要開方,速度快,效能較高
當我們得知某個邊界方塊的當前代價和預估代價,我們就可以通過把這兩個數值相加便可以得到它的總代價,即:
總代價 = 當前代價 + 預估代價
每一輪尋路我們都尋找當前邊界裡總代價最低的方塊進行探索,並和廣度優先演算法類似的記錄其來向並標記自身,直到探索到終點為止,我們就可以通過 A-Star 演算法獲得相應的最短路徑。
相比於廣度優先演算法,A-Star 演算法由於具有一定的方向性,因此一般而言它比廣度優先演算法少了許多無用的探索,遍歷地圖格子的數量也少很多,因此一般情況下整體的效能會比廣度優先演算法要好。
至此,我們已經有了合適的搜尋演算法,下一步就是要看看怎麼樣去生成我們的導航地圖了。
自建導航網格
在講如何自建導航網格之前,我們必須瞭解一個概念,那就是在 3D 場景中的模型,都是由一個又一個的三角面構成的,因此在構建導航網格時,我們也要遵循這個原則來進行設計。
Recast 中導航網格的生成原理
那麼我們可以簡單瞭解一下在上文提到的 Recast 中,構建一個導航網格需要經過哪幾個步驟?簡單來說一共是六步。
- 場景模型體素化(Voxelization),或者叫“柵格化”(Rasterization)。簡單來說就是將三角面資料轉換為畫素資訊(也叫體素資訊),可以理解為是從一個一個三角形面轉換成了一個個的點陣資訊。
- 過濾出可行走面(Walkable Suface),即通過第一步得到的體素資訊計算出體素頂部可行走面的資料。
- 生成 Region,在獲得可行走面後,我們通過一些演算法將這個面切割成一個個儘量大的、連續的、不重複的且中間沒有洞的區域,這些區域成為 Region。
- 生成簡化邊緣(Simplified Contours),通過上一步得到的 Region 資訊,算出每個 Region 的邊緣資訊,再通過一些簡化演算法對邊緣輪廓進行簡化,我們稱這個簡化輪廓為(Simplified Contours)。
- 生成 Poly Mesh,我們通過對簡化輪廓進行劃分,把每個簡化輪廓劃分成多個凸多邊形,每個凸多邊形我們可以稱之為一個 Poly,它是尋路演算法裡的基本單元。
- 生成 Detailed Mesh,最後我們對每個 Poly 進行三角形化,將它劃分成多個三角形,生成我們最後搜尋演算法需要用的導航網格。
這裡值得一提的是,場景模型在經過第三步生成 Region 後,三維的場景其實已經被簡化成類似二維的存在了,方便了後續的一些計算和操作。但與此同時,這一步也導致模型在移動時沒辦法完全垂直於地表,只能一直保持垂直於 xOz 座標平面的狀態。
結合實際情況的導航網格的生成方式
而在我們這次的沙盒遊戲當中,其實地圖和建築都是相當簡單的,地圖可以簡單地看作一個 64*64 的正方形平面,而建築也可以簡化成一個個簡單的正方體。
參考上述 Recast 網格導航的生成步驟,最後生成出來的是一份不重疊的網格資料。
由於我們的沙盒地圖並不是特別的大,因此,我們導航網格的生成方式其實就很簡單了,一句話概括:直接用 64*64 正方形平面的頂點資料作為原始的導航網格資料,再將與建築物在地面的正方形投影接觸的頂點資料排除掉,最後剩下頂點資料所構成的網格就是我們需要的導航網格了。
在文章的後續我們會更加詳細地講到該生成方式,此處讀者有個基本的概念即可。
A-Star 搜尋演算法與導航網格的結合
至此,我們已經基本瞭解了 A-Star 搜尋演算法的原理和導航網格的生成方式了,是時候將他們組裝結合起來了。
首先我們前面在講 A-Star 搜尋演算法的時候,我們假設地圖是由 1X1 的正方形格子組成的,但在實際情況裡,由於 3D 場景下的物體都是由三角面組成的,因此我們的導航網格也是由一個個三角形構成的,因此我們 A-Star 演算法中的一些計算也要進行相應的調整。
在計算總代價的值時,我們會像上述說的那樣,將總代價分成當前代價和預估代價兩方面,在當前代價方面,我們仍然按照當前已走過的路徑數來進行計算。
而預估代價則使用尤拉距離來進行計算,這裡的計算會從正方形格子間的距離計算轉為三角面間的距離計算,因此我們需要計算每個三角面的中心點,格子間的距離我們會使用中心點間的距離來進行代替,尤拉距離的值則為邊界三角面的中點到終點三角面中點的距離。
接下來我們需要對地圖頂點資料進行處理,將頂點資料中與建築物投影存在交集的頂點過濾出來,然後求出過濾後頂點資料組成的每個三角形的中心點,以及與這個三角形相鄰的三角形列表(neighbours) 以及他們的鄰邊資料(portals)。
最後我們傳入起點和終點資料,A-Star 演算法會根據起點資料和終點資料找到這個點所在的對應的三角形面資料,然後從起點三角形出發,根據其相鄰三角形所對應的代價值進行尋路,直至找到終點三角形後,返回路徑資料(paths)。
最後,我們通過讓人物沿著返回的路徑資料進行相應的移動,即可達到對應的避障效果。
實際應用下的優化
其實到此為止,我們已經基本實現了沙盒遊戲的尋路演算法了,但是在測試過程中我們發現,由於得到的路徑經過了太多的三角形節點,導致人物在行走過程中出現了過多的拐點,且一些三角形之間的路徑明顯可以通過一條直線來表示,但是實際上中間卻需要經過許多三角形的中點。
因此,我們引入了拉繩演算法,通過拉繩演算法解決路徑拐點太多的問題,在這裡就不過多介紹拉繩演算法了,有興趣的可以通過文章拉繩演算法之漏斗演算法來進行了解。
通過加入拉繩演算法後,人物行走的路徑也得到了優化,最後產生的實際效果也基本與理想效果達到一致了。
後續的待優化項
目前我們的尋路演算法其實並不是我心目中的最優解。因為在本次導航網格的生成過程中,由於我們把整張地圖都抽象成網格的形式,導致圖的節點太多,在地圖較大的情況下遍歷起來會非常低效。
因此我們需要把網格地圖簡化成節點更少的路標形式(Waypoints),去對導航網格中一些不必要的點和麵,對他們進行刪減和合並。
另外,我們還能加入八叉樹的概念,在三維空間中利用 x, y, z 軸將空間劃分為 8 個部分,來優化查詢效率。
鑑於文章的篇幅,這裡就不對這些內容進行過多的展開了,希望讀者秉持著好學的心態,自己在讀後繼續進行深入的研究。