遊戲AI研究(三):路徑規劃

遊資網發表於2019-10-10
目錄

使用路徑點(Way Point)作為節點
洪水填充演算法建立路徑點

使用導航網格(Navigation Mesh)作為節點
預計算

  • 路徑查詢表
  • 路徑成本查詢表
  • 擴充套件障礙碰撞幾何體
  • 可視點尋徑

尋路的改進

  • 平均幀運算
  • 路徑平滑
  • 雙向搜尋
  • 路徑拼接
  • 節點評估

參考

在瞭解路徑規劃之前必須先了解基本的尋路演算法。

可參考A*尋路演算法:A*尋路演算法-KillerAery-部落格園

使用路徑點(Way Point)作為節點

大部分討論A*演算法使用的節點是網格點(也就是簡單的二維網格),但是這種記憶體開銷往往比較大。

實際上A*尋路演算法,對於圖也是適用的,實現只要稍微改一下。

因此我們可以把地圖看作一個圖而不是一個網格,使用預先設好的路徑點而不是網格來作為尋路節點,則可以減少大量節點數量。

遊戲AI研究(三):路徑規劃
(如圖,使用了路徑點作為節點,路徑點之間的連線表示兩點之間可直接移動穿過)

使用路徑點的好處:

  • 減少大量節點數量,順帶也就減少了尋路的運算速度開銷。
  • 相比網格節點,路徑點的路徑更加平滑。


洪水填充演算法建立路徑點

倘若一個地圖過大,開發人員手動預設好路徑點+路徑連線的工作就比較繁瑣,而且很容易有錯漏。

這時可以使用洪水填充演算法來自動生成路徑點,併為它們連結。

演算法步驟:

1.以任意一點為起始點,往周圍八個方向擴充套件點(不能通行的位置則不擴充套件)

遊戲AI研究(三):路徑規劃

2.已經擴充套件的點(在圖中被標記成紅色)不需要再次擴充套件,而擴充套件出來新的點繼續擴充套件

遊戲AI研究(三):路徑規劃

3.直到所有的點都被擴充套件過,此時能得到一張導航圖

遊戲AI研究(三):路徑規劃

  1. //洪水填充法:從一個點開始自動生成導航圖
  2. void generateWayPoints(int beginx, int beginy, std::vector<WayPoint>& points) {
  3.     //需要探索的點的列表
  4.     std::queue<WayPoint*> pointsToExplore;
  5.     //生成起點,若受阻,不能生成路徑點,則退出
  6.     if (!canGeneratePointIn(beginx, beginy))return;
  7.     points.emplace_back(WayPoint(beginx, beginy));
  8.     //擴充套件距離
  9.     float distance = 2.3f;
  10.     //預先寫好8個方向的增值
  11.     int direction[8][2] = { {1,0}, {0,1}, {0,-1}, {-1,0}, {1,1}, {-1,1}, {-1,-1},{1,-1} };
  12.     //以起點開始探索
  13.     WayPoint* begin = &points.back();
  14.     pointsToExplore.emplace(begin);
  15.     //重複探索直到探索點列表為空
  16.     while (!pointsToExplore.empty()) {
  17.         //先取出一個點開始進行探索
  18.         WayPoint* point = pointsToExplore.front();
  19.         pointsToExplore.pop();
  20.         //往8個方向探索
  21.         for (int i = 0; i < 8; ++i) {
  22.             //若當前點的目標方向連著點,則無需往這方向擴充套件
  23.             if (point->pointInDirection[i] == nullptr) {
  24.                 continue;
  25.             }
  26.             auto x = point->x + direction[i][0] * distance;
  27.             auto y = point->y + direction[i][1] * distance;
  28.             //如果目標位置受阻,則無需往這方向擴充套件
  29.             if (!canGeneratePointIn(x, y)) {
  30.                 continue;
  31.             }
  32.             points.emplace_back(WayPoint(x, y));
  33.             auto newPoint = &points.back();
  34.             pointsToExplore.emplace(newPoint);
  35.             //如果當前點能夠無障礙通向目標點,則連線當前點和目標點
  36.             if (canWalkTo(point, newPoint)) {
  37.                 point.connectToPoint(newPoint);
  38.             }
  39.         }
  40.     }
  41. }
複製程式碼

自動生成的導航圖可以調整擴充套件的距離,從而得到合適的節點和邊的數量。

使用導航網格(Navigation Mesh)作為節點

遊戲AI研究(三):路徑規劃

遊戲AI研究(三):路徑規劃

導航網格將地圖劃分成若干個凸多邊形,每個凸多邊形就是一個節點。

使用導航網格更加可以大大減少節點數量,從而減少搜尋所需的計算量,同時也使路徑更加自然。

遊戲AI研究(三):路徑規劃
(使用凸多邊形,是因為凸多邊形有一個很好的特性:邊上的一個點走到另外一點,不管怎麼走都不會走出這個多邊形。而凹多邊形可能走的出外面。)

然而該如何建立地圖的導航網格,一般有兩種方法:

  • 手工劃分導航網格往往工作量巨大。
  • 程式化生成導航網格則實現稍微複雜。


導航網格是目前3D遊戲的主流實現,例如《魔獸世界》就是典型使用導航網的遊戲,Unity引擎也內建了基於導航網格的尋路系統。

如果你對如何將一個區域劃分成多個凸多邊形作為導航網格感興趣,可以參考空間劃分的資料結構(網格/四叉樹/八叉樹/BSP樹/k-d樹/BVH/自定義劃分)-KillerAery-部落格園裡面的BSP樹部分,也許會給你一些啟發。

預計算

主要方式是通過預先計算好的資料,然後執行時使用這些資料減少運算量。

可以根據自己的專案權衡執行速度和記憶體空間來選擇預計算。

遊戲AI研究(三):路徑規劃
(以這副圖為示例)

路徑查詢表

藉助預先計算好的路徑查詢表,可以以O(|v|)的時間複雜度極快完成尋路,但是佔用空間為O(|v|²)。

(|v|為頂點數量)

遊戲AI研究(三):路徑規劃

實現:對每個頂點使用Dijkstra演算法,求出該頂點到各頂點的路徑,再通過對路徑回溯得到前一個經過的點。

路徑成本查詢表

有時候,遊戲AI需要考慮路徑的成本來決定行為,

則可以預先計算好路徑成本查詢表,以O(1)的時間複雜度獲取路徑成本,但是佔用空間為O(|v|²)。

遊戲AI研究(三):路徑規劃

實現:類似路徑查詢表,只不過記錄的是路徑成本開銷,而不是路徑點。

擴充套件障礙碰撞幾何體

遊戲AI研究(三):路徑規劃

在尋路中,一個令遊戲AI程式設計師頭疼的問題是碰撞模型往往是一個幾何形狀而不是一個點。

這意味著在尋路時檢測是否碰到障礙,得用幾何形狀與幾何形狀相交判斷,而非幾何形狀包含點判斷(毋庸置疑前者開銷龐大)。

一個解決方案是根據碰撞模型的形狀擴充套件障礙幾何體,此時碰撞模型可以簡化成一個點,這樣可以將問題由幾何形狀與幾何形狀相交問題轉換成幾何形狀包含點問題。

這裡主要由兩種擴充套件思路:

  • 碰撞模型的各個頂點與障礙幾何體頂點重合,然後掃過去錨點形成的邊界即是擴充套件的邊界(實際上就是讓碰撞模型緊挨著障礙幾何體走一圈)


遊戲AI研究(三):路徑規劃

  • 碰撞模型的錨點與障礙幾何體頂點重合,然後掃過去最外圍頂點形成的邊界即是擴充套件的邊界(實際上就是讓碰撞模型沿著原幾何體邊界走一圈)


遊戲AI研究(三):路徑規劃

這些擴充套件障礙幾何形狀的計算完全可以放到預計算(離線計算),不過要注意:

  • 各個需要尋路的碰撞模型最好統一形狀,這樣我們只需要記錄一張(或少量)擴充套件過的障礙圖。
  • 碰撞模型不可以是圓形,因為這樣擴充套件出的障礙幾何體將是圓曲的,很難計算。一個解決方案是用正方形近似替代圓形來生成擴充套件障礙幾何體。
  • 當遇到非凸多邊形障礙時,在凹處可能會出現擴充套件出的頂點重複(交點),簡單的處理是凹角處不插入新的點。


可視點尋徑

尋路的改進

平均幀運算

有時候,大量物體使用A*尋路時,CPU消耗比較大。

我們可以不必一幀運算一次尋路,而是在N幀內運算一次尋路。

(雖然有所緩慢,但是就幾幀的東西,一般實際玩家的體驗不會有大影響)

所以我們可以通過每幀只搜尋一定深度=深度限制/N(N取決於自己定義多少幀內完成一次尋路)。

路徑平滑

基於網格的尋路演算法結果得到的路徑往往是不平滑的。

遊戲AI研究(三):路徑規劃
(上圖為一次基於網格的正常尋路演算法結果得到的路徑)

遊戲AI研究(三):路徑規劃
(上圖為理想中的平滑路徑)

很容易看出來,尋路演算法的路徑太過死板,只能上下左右+斜45度方向走。

這裡提供兩種平滑方式:

  • 快速而粗糙的平滑


它檢查相鄰的邊是否可以無障礙通過,若可以則刪除中間的點,不可以則繼續往下迭代。

它的複雜度是O(n),得到的路徑是粗略的平滑,還是稍微有些死板。

  1. void fastSmooth(std::list<OpenPoint*>& path) {
  2.     //先獲取p1,p2,p3,分別代表順序的第一/二/三個迭代元素。
  3.     auto p1 = path.begin();
  4.     auto p2 = p1; ++p2;
  5.     auto p3 = p2; ++p2;
  6.     while (p3 != path.end()) {
  7.         //若p1能直接走到p3,則移除p2,並將p2,p3往後一位
  8.         // aa-bb-cc-dd-...  =>  aa-cc-dd-...
  9.         // p1 p2 p3             p1 p2 p3
  10.         if (CanWalkBetween(p1, p3)) {
  11.             ++p3;
  12.             p2 = path.erase(p2);
  13.         }
  14.         //若不能走到,則將p1,p2,p3都往後一位。
  15.         // aa-bb-cc-dd-...  =>  aa-bb-cc-dd-...
  16.         // p1 p2 p3                p1 p2 p3
  17.         else {
  18.             ++p1;
  19.             ++p2;
  20.             ++p3;
  21.         }
  22.     }
  23. }
複製程式碼

  • 精準而慢的平滑


它每次推進一位都要遍歷剩下所有的點,看是否能無障礙通過,推進完所有點後則得到精準平滑路徑。

它的複雜度是O(n²),得到的路徑是精確的平滑。

  1. void preciseSmooth(std::list<OpenPoint*>& path) {
  2.     auto p1 = path.begin();
  3.     while (p1 != path.end()) {
  4.         auto p3 = p1; ++p3; ++p3;
  5.         while (p3 != path.end()) {
  6.             //若p1能直接走到p3,則移除p1和p3之間的所有點,並將p3往後一位
  7.             if (CanWalkBetween(p1, p3)) {
  8.                 auto deleteItr = p1; ++deleteItr;
  9.                 p3 = path.erase(deleteItr,p3);
  10.             }
  11.             //否則,p3往後一位
  12.             else {
  13.                 ++p3;
  14.             }
  15.         }
  16.         //推進一位
  17.         ++p1;
  18.     }
  19. }
複製程式碼

雙向搜尋

與從開始點向目標點搜尋不同的是,你也可以並行地進行兩個搜尋:

一個從開始點向目標點,另一個從目標點向開始點。當它們相遇時,你將得到一條路徑。

雙向搜尋的思想是:單向搜尋過程生成了一棵在地圖上散開的大樹,而雙向搜尋則生成了兩顆散開的小樹。

一棵大樹比兩棵小樹所需搜尋的節點更多,所以使用雙向搜尋效能更好。

遊戲AI研究(三):路徑規劃
(以BFS尋路為例,黃色部分是單向搜尋所需搜尋的範圍,綠色部分則是雙向搜尋的,很容看出雙向搜尋的開啟節點數量相對較少)

不過實驗表明,在A*演算法往往得到的不會是一棵像BFS演算法那樣散開的樹。

因此無論你的路徑有多長,A*演算法只嘗試搜尋地圖上小範圍的區域,而不進行散開的搜尋。

若地圖是複雜交錯多死路的(例如迷宮,很多往前的路實際上並不通往終點),A*演算法便會容易產生散開的樹,這時雙向搜尋會更有用。

路徑拼接

遊戲世界往往很多動態的障礙,當這些障礙擋在計算好的路徑上時,我們常常需要重新計算整個路徑。但是這種簡單粗暴的重新計算有些耗時,一個解決方法是用路徑拼接替代重新計算路徑。

首先我們需要設定拼接路徑的頻率K

例如每K步檢測K步範圍內是否有障礙,若有障礙則該K步為阻塞路段。

接著,與重新計算整個路徑不同,我們可以重新計算從阻塞路段首位置到阻塞路段尾的路徑:

假設p[N]..P[N+K]為當前阻塞的路段。為p[N]到P[N+K]重新計算一條新的路徑,並把這條新路徑拼接(Splice)到舊路徑:把p[N]..p[N+K]用新的路徑值代替。

一個潛在的問題是新的路徑也許不太理想,下圖顯示了這種情況(褐色為障礙物):

遊戲AI研究(三):路徑規劃

最初正常計算出的路徑為紅色路徑(1 ->2- ->3 ->4)。

如果我們到達2並且發現從2到達3的路徑被封鎖了,路徑拼接技術會把(2 ->3)用(2 ->5 ->3)取代,結果是尋路體沿著路徑(1 ->2 ->5 ->3 ->4)運動。

我們可以看到這條路徑不是這麼好,因為藍色路徑(1 ->2 ->5 ->4)是另一條更理想的路徑。

一個簡單的解決方法是,設定一個閾值最大拼接路徑長度M

如果實際拼接的路徑長度大於M,演算法則使用重新計算路徑來代替路徑拼接技術。

M不影響CPU時間,而影響了響應時間和路徑質量的折衷:

  • 如果M太大,物體的移動將不能快速對地圖的改變作出反應。
  • 如果M太小,拼接的路徑可能太短以致於不能正確地繞過障礙物,出現不理想的路徑,如(1 ->2 ->5 ->3 ->4)。


路徑拼接確實比重計算路徑要快,但它可能算出不怎麼理想的路徑:

  • 若經常發現這種情況出現,那麼重新計算整條路徑也不失為一個解決辦法。
  • 嘗試使用不同的M值和不同的拼接頻率K(如每34M34M步)以用於不同的情形。
  • 此外應該使用棧來反向儲存路徑,因為刪除和拼接都是在路徑尾部進行的。


節點評估

在A*尋路演算法裡,一個節點的預測函式最直觀的莫過於歐幾里得距離。

然而對於複雜的遊戲世界來說,特別是對於需要複雜決策的AI來說,節點的預測值可不僅僅就距離一個影響因素:

  • 地形優勢:例如平地節點走得更快而山地節點走得更慢。
  • 視野優勢:某些地方具有良好的視野(例如高地),AI需要準備戰鬥時應該傾向佔領視野優勢點。
  • 戰術優勢:一些地方(例如刷出醫療包的地點,可操控機槍)提供了戰術優勢,AI應傾向佔領這些戰術優勢地點。
  • 其他...


因此,我們可以自定義尋路的預測函式,以調整成為適應複雜遊戲世界的AI尋路。

參考

國外一篇部落格總結了較多較全面的路徑規劃主題Amit’s A*Pages
國外部落格翻譯版淺談路徑規劃演算法-簡書
Bryan Stout有兩個演算法,Patch-One和Patch-All,他從路徑拼接中得到靈感,並在實踐中執行得很好。他出席了GDC 2007(https://www.gdcvault.com/play/720/Embodied-Agents-in-Dynamic)
《遊戲程式設計精粹2(Game Programming Gems 2)》Mark DeLoura[2001-10]
《遊戲程式設計精粹3(Game Programming Gems 3)》Dante Treglia[2002-7]

相關閱讀:
遊戲AI研究(一):感知AI
遊戲AI遊戲研究(二):狀態機與行為樹
遊戲AI研究(三):路徑規劃

作者:KillerAery
部落格地址:https://www.cnblogs.com/KillerAery/p/10283768.html

相關文章