3D 沙盒遊戲之避障踩坑和實現之旅

背景

最近在實現一個 3D 的沙盒類遊戲,基本的功能就是在一個 3D 平面裏,進行建築物的搭建,可以在場景內添加或者編輯建築物,然後平面內存在一個人物模型,他可以穿梭行走於建築物之間。

沙盒遊戲

在實現人物的行走功能的時候,我們很自然地想到取終點和起點兩點的座標,然後連成直線進行行走即可,在沒有建築物的時候這個想法確實很好,可是一旦在地圖中出現建築物之後就會發現:忒喵的人物穿模啦!

人物穿模

至此,尋找避障方案之旅開始了。

使用 Babylon.js 自帶的避障功能

由於我們的項目是使用 Babylon.js 框架來進行開發的,因此本着“能不自己寫堅決不自己寫”的原則,我們的第一個想法就是使用 Babylon.js 自帶的避障功能。

介紹

簡單介紹一下 Babylon.js 中自帶的避障功能,它屬於框架的一種拓展功能,需要結合 RecastJS 依賴進行使用。

利用 RecastJS 進行導航網格(Navigation Mesh)的生成。然後再通過 RecastJS 中的 Crowd Agents 模塊進行自動的尋路和避障。

Crowd Navigation System 介紹

可能看到這裏的你會有一個疑問,什麼是導航網格,這裏引用一段我在其他博客中看到的定義和介紹:“導航網格(Navigation Mesh),也俗稱行走面,是一種用於在複雜空間中導航尋路、標記哪些地方可行走的多邊形網格數據結構。”

導航網格

一個導航網格是由許多的凸多邊形組成的,大白話地講就是將地圖切割成 N 份,每份都是一個凸多邊形,我們稱一個多邊形爲一個 Poly

然後當人物在導航網格中行走時,會判斷起點和終點是否在同一個 Poly 中,如果是的話,則直接將起點和終點連成直線,該直線即爲人物的行走路線;否則就需要使用相應的尋路算法進行路徑的計算,計算出人物需要經過哪些 Poly,再利用這些 Poly 算出具體路徑。

應用

在知道 Babylon.js 自帶了這麼一個功能的時候,當時我的內心是狂喜的,於是乎照着官網示例對着鍵盤一頓亂按後,刷新頁面,新增一個建築,點擊地圖,期待着人物模型自動地繞開建築物,結果:

失敗

What happened?

經驗告訴我,應該是導航網格沒有正常生成導致的,幸好,RecastJS 提供了多樣化的參數讓我可以去調整導航網格的生成規則。

於是乎,經過了幾乎一整天的參數調整,最後發現是我太天真了,結果根本沒有發生什麼變化。只有當添加的障礙物體積很小很小的時候,人物的避障功能才能成功生效,當障礙物的體積稍微大一點點,人物就會直直地穿過建築到達終點。

當時我的想法是:行吧,既然調參也沒有用,那我去看看 RecastJS 的源碼,看看它是如何生成導航網格的,以及是什麼導致它導航網格生成不正常的。

所以,我轉戰到了 github。不搜不知道,一搜嚇一跳,發現這個 RecastJS 原來前身是一個 C 語言開發的庫,後來不知道被誰轉成了 Javascript 版本發到了 npm 上,也就是說,我們看不到 Recast 的 Javascript 版本源代碼。

無源碼

額,好吧,使用 Babylon.js 自帶的避障功能這條路算是徹底堵死了。

自建導航網格和搜索算法

既然自帶的功能沒用成,而且在 Babylon.js 的社區生態中尚未有更好的解決方案,那麼就只能自力更生了,打造一套適用於當前場景的導航網格和搜索算法。

我們先來理一下整體的思路和流程:

  1. 獲取地圖數據,生成合適的導航網格
  2. 獲取人物行走的起點和終點
  3. 利用搜索算法在導航網格中找到起點到終點的最短路徑
  4. 操作人物按照最短路徑進行行動

選擇合適的搜索算法

先來講講搜索算法,說起尋路的搜索算法,最先讓人想到的應該是大名鼎鼎的廣度優先搜索算法。

廣度優先搜索

廣度優先搜索算法是一種很常用的尋路算法,被廣泛地應用在計算機的各種場景,比如 Windows 的畫板塗鴉功能,就是用廣度優先算法實現的。

畫板塗鴉,圖片來源參考資料(4)

我們可以將整個地圖簡單地拆分成 N 個 1X1 的正方形格子,廣度優先算法的基本原理很簡單,就是從起點格子出發,每次可以朝上下左右進行移動。下圖中的綠色標記點是起點,而紅色標記點是終點。

起點和終點,圖片來源參考資料(4)

每一輪探索完畢之後,會標記這一輪探索過的方塊爲邊界(Frontier),也就是下圖中的綠色正方形格子。

邊界,圖片來源參考資料(4)

然後算法會從這些邊界方塊開始,逐一繼續下一輪的探索,直到在探索過程中找到了終點方塊後,算法纔會停止探索。

廣度優先算法,圖片來源參考資料(4)

在每次探索時,我們需要順勢記錄路徑的來向,也就是形成一個類似鏈表的結構,在到達終點後,我們便可以根據這個來向的記錄找到對應的最短路徑。

另外,每一輪所探索的路徑格子在下一輪探索開始前要對其進行標記,讓算法知道這個格子已經被遍歷過了,之後的探索過程中如果遇到了被標記的格子,將直接跳過不會納入後續的探索輪次。下圖中灰色的格子即爲被標記不再探索的格子。

標記已走過的點,圖片來源參考資料(4)

其實廣度優先算法實現起來很簡單,應用場景也很廣,但是它也有很明顯的缺點,那就是它很蠢,最壞情況下需要遍歷整張地圖才能找到目標點,所以很容易因爲移動次數過多導致出現性能問題。

A-Star 算法

爲了解決廣度優先算法的性能問題,於是乎我們引入了啓發式搜索 A-Star 算法,與廣度優先算法有所不同的是,我們在每一輪搜索的時候,不會去探索所有的邊界方塊,而是會選擇當前代價(cost)最低的方向進行探索,因此它是具有一定的方向性的,它前進的方向取決於當前邊界方塊裏最低代價方塊所在的位置。

代價分成兩部分,一部分是當前路程代價 ,或者叫做當前代價(f-cost),它表示你當前已走過的路徑數量,比如當前格子需要走三步才能到達,則當前代價爲 3。

當前代價,圖片來源參考資料(4)

另一部分代價是預估代價(g-cost),表示從當前方塊到終點方塊大概需要多少步,由於它叫“預估”代價,因此它並不是一個精確的數值,這個估計值主要是用於指導算法去優先搜索更有希望的路徑。

這裏介紹兩種常用的預估代價:

  • 歐拉距離(Euler Distance):當前格子到終點的直線距離,用數學公式來表示的話就是 Math.sqrt((x1 - x2)^2 + (y1 - y2)^2)
    歐拉距離,圖片來源參考資料(4)
  • 曼哈頓距離(Manhattan Distance):指當前格子和終點兩點在豎直方向和水平方向上的距離總和,用數學公式來表示就是 |x1 - x2| + |y1 - y2|,曼哈頓距離的計算不需要開方,速度快,性能較高
    曼哈頓距離,圖片來源參考資料(4)

當我們得知某個邊界方塊的當前代價和預估代價,我們就可以通過把這兩個數值相加便可以得到它的總代價,即:

總代價 = 當前代價 + 預估代價

每一輪尋路我們都尋找當前邊界裏總代價最低的方塊進行探索,並和廣度優先算法類似的記錄其來向並標記自身,直到探索到終點爲止,我們就可以通過 A-Star 算法獲得相應的最短路徑。

相比於廣度優先算法,A-Star 算法由於具有一定的方向性,因此一般而言它比廣度優先算法少了許多無用的探索,遍歷地圖格子的數量也少很多,因此一般情況下整體的性能會比廣度優先算法要好。

算法對比,圖片來源參考資料(4)

至此,我們已經有了合適的搜索算法,下一步就是要看看怎麼樣去生成我們的導航地圖了。

自建導航網格

在講如何自建導航網格之前,我們必須瞭解一個概念,那就是在 3D 場景中的模型,都是由一個又一個的三角面構成的,因此在構建導航網格時,我們也要遵循這個原則來進行設計。

Recast 中導航網格的生成原理

那麼我們可以簡單瞭解一下在上文提到的 Recast 中,構建一個導航網格需要經過哪幾個步驟?簡單來說一共是六步。

  1. 場景模型體素化(Voxelization),或者叫“柵格化”(Rasterization)。簡單來說就是將三角面數據轉換爲像素信息(也叫體素信息),可以理解爲是從一個一個三角形面轉換成了一個個的點陣信息。
    體素化,圖片來源參考資料(2)

  2. 過濾出可行走面(Walkable Suface),即通過第一步得到的體素信息計算出體素頂部可行走面的數據。
    過濾可行走面,圖片來源參考資料(2)

  3. 生成 Region,在獲得可行走面後,我們通過一些算法將這個面切割成一個個儘量大的、連續的、不重複的且中間沒有洞的區域,這些區域成爲 Region。
    生成 Region,圖片來源參考資料(2)

  4. 生成簡化邊緣(Simplified Contours),通過上一步得到的 Region 信息,算出每個 Region 的邊緣信息,再通過一些簡化算法對邊緣輪廓進行簡化,我們稱這個簡化輪廓爲(Simplified Contours)。
    生成簡化輪廓,圖片來源參考資料(2)

  5. 生成 Poly Mesh,我們通過對簡化輪廓進行劃分,把每個簡化輪廓劃分成多個凸多邊形,每個凸多邊形我們可以稱之爲一個 Poly,它是尋路算法裏的基本單元。
    生成 Poly Mesh,圖片來源參考資料(2)

  6. 生成 Detailed Mesh,最後我們對每個 Poly 進行三角形化,將它劃分成多個三角形,生成我們最後搜索算法需要用的導航網格。
    生成導航網格,圖片來源參考資料(2)

這裏值得一提的是,場景模型在經過第三步生成 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 個部分,來優化查找效率。
八叉樹

鑑於文章的篇幅,這裏就不對這些內容進行過多的展開了,希望讀者秉持着好學的心態,自己在讀後繼續進行深入的研究。

參考鏈接

  1. Navigation Mesh (NavMesh) 原理講解
  2. 遊戲的尋路導航 1:導航網格
  3. 最短路徑搜尋:A*尋路算法講解
  4. A*尋路算法詳解 #A星 #啓發式搜索
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章