[譯]尋路優化

看到一篇關於尋路優化的文章,簡單翻譯了一下,原文在這裏

  • 譯文並未照搬原文翻譯,多處是意譯
  • 原文圖片已經失效,不過其他轉載網址仍有圖片,我對應着補了一下

尋路對很多遊戲來講都是不可或缺的元素,在一般的遊戲中,使用一些基本的尋路算法(譬如 BFS, Dijkstra 或者 A* 等等)就可以很好的解決尋路問題,但是在另一些遊戲中,尤其是在遊戲地圖比較龐大的情況下,這些基本尋路算法需要耗費大量的時間進行尋路,進而造成遊戲卡頓,這使得尋路優化變得非常重要.

在這篇文章中,我會簡單介紹一下 A* 算法以及該算法的一些改進點,我也會講解一些常用的 A* 衍生算法以及 HPA 算法的一些實現要點.

重溫 A* 算法

A* 算法用於尋找從開始點至目標點之間的一條可達路徑.A* 算法在尋路過程中會使用一種簡單的方法來評估當前節點與目標點之間的距離.通過將已經經過的路徑距離和預估的路徑距離相加,算法會首先擴展搜索那些最有"前途"(與目標點距離最短)的節點.A* 算法的尋路方式保證其一定可以找到最優路徑.

在這裏插入圖片描述

從上圖中我們可以看出,從白色的開始點出發,A* 算法搜索了開始點附近的所有節點並沿着離目標點最近的節點找到了一條可達路徑.當 A* 算法找到目標點後,他就通過回溯父節點的方式來重建路徑.

以下是我們實現 A* 算法的方式:

  1. 將開始點放入開放列表(open list)中

  2. 當開放列表不爲空時我們重複執行以下操作:

  3. 從開放列表中取出 F 值最小的節點並將他放入關閉列表中(我們後續不會再考慮關閉列表中的節點)

  4. 對於該節點每一個不在關閉列表中的相鄰節點:

  5. 將該節點設置爲當前相鄰節點的父節點(主要用於後面的節點回溯)

  6. 計算當前相鄰節點的 G 值(從開始點到當前相鄰點的距離)並將其加入到開放列表中

  7. 計算當前相鄰節點的 F 值(通過將當前相鄰節點的 H 值(當前相鄰節點到目標點的預估距離)與當前相鄰節點的 G 值相加)

基本優化

存在很多調整方法可以優化 A* 算法,這些方法能讓 A* 算法執行的更快(但是加速程度不如一些對 A* 進行算法層面優化的方法),另外的,這些方法在某些情況下也並不一定能得到最優的尋路結果,但是對於較空曠(不包含大量阻擋)的遊戲地圖,這些方法的尋路結果也已經足夠好了.如果你想加速 A* 算法但是又不想對其實現進行大幅改動的話,你可以參考以下的幾點建議:

  • 使用有序列表. A* 算法的每一次搜索都需要找到具有最低 F 值的節點,通過使用有序列表,我們就可以在列表的頭部位置方便的找到該節點(譯註:原文中的意思是使用無序列表,疑有誤或者有其他指代意義,譯文改爲有序列表)

  • 使用 字典(或者說優先級隊列) 或者 堆 來替代 列表 也可以加速 A* 算法.在這些數據結構中遍歷元素非常之快,這會非常有助於你在其中搜索某一節點,同樣的,在有序字典或者最小堆中,我們也能很方便的找到具有最低 F 值的節點.

  • 分幀尋路.如果你的遊戲並不需要在一幀中就獲取完整的尋路結果,那麼我們就可以使用分幀尋路來優化 A* 算法.我們可以設置一個循環上限,如果 A* 算法在該循環限制內沒能完成尋路,我們便暫停當前尋路,並在下一幀繼續.(譯註:原文的意思應該是分段尋路,方法是如果在設置的循環限制內不能完成尋路的話,下一幀就從最後一個搜索節點開始重新尋路,這種方法並不一定能正確得到尋路結果,譯文調整爲分幀尋路)

  • 節點中保存 is_open 或者 is_close 變量.你可以在節點中保存一個變量,用以表示節點是否在開放列表中或者關閉列表中.通過這種方式,當你需要搜索一個列表中的節點時,你就可以不用在整個列表中搜索節點,而是直接檢查對應的變量值即可.這種方法可以大幅減少檢查節點是否在列表中的開銷.

  • 在開始實際尋路之前先進行一次低層級的尋路.你可以在原遊戲地圖的基礎上預先構建一張由部分節點構成的地圖,然後在實際真實尋路之前,先在這張低層級地圖上進行尋路,這樣你就可以獲取到一條由部分節點構成的尋路路徑,之後你就可以分幀來搜尋這些(部分)節點之間的路徑,與上述的分幀尋路不同的是,你不用限制循環上限,而是一幀一幀的來尋找(部分)節點之間的路徑.

算法優化

所謂算法優化,是指那些會改變算法搜尋節點方式的優化.每一個對於算法搜尋節點方式(基於地圖分佈方式或者角色移動方式)的微小改變都可能極大的改善尋路算法的效率.值得一提的是,根據遊戲地圖的動態程度不同,算法優化的效果也不盡相同.目前有很多關於 A* 的算法優化方式,我們這裏只會談論其中的兩種: HPA 和 JPS.

HPA

分層尋路會將原始地圖預處理成一張更低層級的地圖,其中原始地圖會被分爲多個簇(塊),這些簇之間的距離和最優路徑會被預先計算並緩存起來.實際尋路時,首先在更低層級的地圖上(即簇之間)進行尋路,然後,我們根據之前預先計算緩存的(簇之間的)最優路徑來獲得一條到達目標點的路徑.

在這裏插入圖片描述

正如我們在上圖中所看到的,各個網格(節點)都按簇的方式進行劃分,並且在這些簇上有用於連接相鄰簇的出口,一旦我們將簇的出口(也是網格,或者說節點)進行相連,我們就可以得到簇從一條邊(出口)到另一條邊(出口)的距離,這些可以預計算的距離對於我們後面搜索路徑非常有幫助.

在這裏插入圖片描述

現在,我們來看個例子,我們想尋找一條從 S 到 G 的路徑,我們首先在低層級地圖上(各個簇之間)進行一次 A* 尋路,然後,我們可以根據預計算數據(簇之間的連通數據)快速的得到一條完整的路徑.

記住一點:你可以自定義網格和簇的創建方式,這聽起來似乎很當然,但是這意味着你可以根據你遊戲地圖的分佈方式來創建網格(和簇).通過自定義網格(和簇),你可以使一些簇變得更大,以使這些簇可以適應整個房間或者其他一些地圖區域.

算法利弊:

每一種優化都有適合的使用情境,如果使用不當,優化效果就會大打折扣. 譬如在動態地圖中, HPA 便 需要不時的重新計算簇之間的距離和路徑,這會消耗很多的時間. 類似的, HPA 也並不是在空曠地圖中尋路的最佳選擇,不過這並不是說 HPA 在空曠地圖上的尋路表現糟糕,而是說另一些尋路算法(譬如 JPS)更適用於這種情況.

JPS

JPS 算法的基本思路就是持續"擴展"節點直到到達無法繼續"擴展"的區域爲止.如果你仔細想一想就會發現, JPS 算法的內涵思想其實挺簡單的:如果我們可以通過其他節點以更短的距離達到某一節點,那麼我們完全可以不在(開放)列表中添加這個節點(因爲這個節點在擴展其他節點時會被評估是否要加到開放列表中).

和 HPA 不同的是, JPS 不需要預計算任何數據,他的優勢在於遍歷開放列表和關閉列表的開銷很小.需要注意的是, JPS 只支持規則網格(節點)的尋路,即使你的遊戲地圖包含不同尋路成本(距離)的網格或者區域, JPS 也只會把他們當做統一成本(距離)的網格或者區域.不過也正因爲只支持規則網格的關係,JPS 才能夠跳過網格的某些擴展方向,️而相對應的, A* 算法則需要擴展網格的所有可能方向.在 JPS 中,算法僅需要擴展被其稱爲 跳躍點(jump point) 的節點,接下來我會解釋 JPS 是如何找到這些跳躍點的.

在講解 JPS 算法的細節之前,我們先聊聊 JPS 的算法基礎: 鄰點裁剪(neighbour pruning)

在這裏插入圖片描述

假設節點 x 正在通過其父節點進行擴展,上圖中我們用箭頭來表示這個"父子"關係.如圖所示,對於其中各個灰色節點而言,我們都可以不經過 x 節點,而是通過 x 的父節點(即 4 號節點)來進行訪問(譯註:意思是這些節點如果經過 x 節點來訪問,其成本(距離)將小於或等於僅經過 x 父節點(4 號節點)來訪問,所以在擴展 x 節點時,我們可以直接忽略這些節點而不進行擴展).現在我們來說下什麼是強制鄰點(forced neighbour):

在這裏插入圖片描述

強制鄰點是指無法從 x 節點擴展到的節點,如上圖所示,如果沿着灰色網格的箭頭方向,我們無法到達紅圈中的節點(譯註:這裏說的有些籠統,我們可以簡單這麼理解,由於阻擋的存在,我們已經不能直接經
x 父節點訪問到紅圈節點),這些節點便稱爲強制鄰點.記住,如果正在擴展的節點旁邊有阻擋的話,阻擋"後面"的節點便是強制鄰點.

算法流程

暫略(譯註:原文在這裏通過示例描述了 JPS 算法在 水平方向 與 對角方向 搜索節點的流程,但是描述的比較簡略,也存在一些錯誤,在此暫時省略翻譯,有興趣的朋友可以閱讀這篇文章來了解 JPS 的算法流程)

算法利弊

正如之前所說,JPS 不能用於非規則網格地圖的尋路;對於其適用的規則網格地圖,地圖的空曠程度越高,JPS 的效率也會越高.另外, JPS 也不需要預處理任何數據,並且支持動態地圖.

如果你發現自己仍然不太理解 JPS 的算法步驟,你可以在這個網站上直觀的查看 A*, Djikstra, JPS 等尋路算法的運行方式.

優化實現

現在,我們來看一個簡單的尋路優化的實現方式,基本思想就是避免開放列表和關閉列表的遍歷.我們首先需要創建一個節點數組.

在這裏插入圖片描述

通過這個節點數組,我們就可以通過網格的位置(索引)直接訪問節點數據,這對於節點遍歷非常有用.一旦我們有了節點數據,我們就可以執行 A* 算法了,我們要做的第一步就是在該數組中填充原始節點,我們使用的填充函數是
std::fill(first item, last item, Node).

在這裏插入圖片描述

注意,size 的大小爲 width * height.我們接下來需要爲開放列表創建優先級隊列.正如我們之前所說,優先級隊列可以讓具有最低 F 值的節點位於隊列頭部,這樣我們就不需要去遍歷搜索該節點了.

在這裏插入圖片描述

如果你不知道上述代碼裏模板參數中的 compare 是什麼,你可以簡單理解是一種定義瞭如何比較節點的簡單數據結構.

在這裏插入圖片描述

下一步就是創建 firstNode 節點指針,並將其加入開放列表中.我使用了 DistanceTo 函數來計算節點的啓發式距離(到目標點的評估距離,即節點的 H 值).

在這裏插入圖片描述

其中 GetPathNode 函數用於通過給定節點位置(索引)獲取對應的節點指針.

在這裏插入圖片描述

代碼寫到這裏,我們就已經準備好進行 while 循環了,我們會使用節點指針來進行循環操作並檢查這些節點指針是否已經在開放列表或者關閉列表中.

在這裏插入圖片描述

我們將當前節點的分值設置爲最低,並且將其 on_close 變量設置爲 true,正常來說,我們應該將節點放置於關閉列表中,但是設置節點變量數據是效率更高的一種方式.OK,現在是時候擴展相鄰節點了,擴展之前我們需要檢查相鄰節點是否已經處於關閉列表中.

在這裏插入圖片描述

循環中我們創建了一個指向當前評估節點的指針 temp,然後我們檢查他的 on_close 和 on_open 變量以獲知其是否在關閉列表中或是在開放列表中.使用這種方法我們就避免了在傳統 A* 算法中最大的一個性能問題:遍歷列表以檢查某一節點是否存在.代碼的其他部分和一般的 A* 算法沒有什麼區別,值得一提的一點是,如果我們找到了一條到某一節點更短的路徑,我們需要重新設置該節點的父節點.

在這裏插入圖片描述

CalculateFopt 是一個用來計算節點 G 值 和 H 值 的函數,方法上主要是檢查了節點間是對角距離還是水平(或垂直)距離.我們需要做的最後一件事是,當我們搜索到目標點後,如何回溯節點直到返回開始點:

我們可以首先保存當前節點,然後一直回溯節點的父節點直到父節點爲空.至此,我們僅通過節點數組便完成了所有的尋路操作(而沒有使用節點列表)!

優化總結

我嘗試在 120x120 的地圖上進行最"困難"的路徑搜索,結果顯示,使用優化過的 A* 算法尋路,時間花費最多是在 20ms 左右,而普通的 A* 算法則需要 200 ~ 600 ms.

你可以在 github 上下載工程文件並自己嘗試下~

參考

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章