目錄html
在瞭解路徑規劃以前必須先了解基本的尋路算法。程序員
可參考A*尋路算法:A*尋路算法 - KillerAery - 博客園算法
大部分討論A*算法使用的節點是網格點(也就是簡單的二維網格),可是這種內存開銷每每比較大。
實際上A*尋路算法,對於圖也是適用的,實現只要稍微改一下。編程
所以咱們能夠把地圖看做一個圖而不是一個網格,使用預先設好的路徑點而不是網格來做爲尋路節點,則能夠減小大量節點數量。數據結構
(如圖,使用了路徑點做爲節點,路徑點之間的連線表示兩點之間可直接移動穿過)函數
使用路徑點的好處:性能
假若一個地圖過大,開發人員手動預設好路徑點+路徑鏈接的工做就比較繁瑣,並且很容易有錯漏。
這時可使用洪水填充算法來自動生成路徑點,併爲它們連接。spa
算法步驟:
1.以任意一點爲起始點,往周圍八個方向擴展點(不能通行的位置則不擴展)翻譯
2.已經擴展的點(在圖中被標記成紅色)不須要再次擴展,而擴展出來新的點繼續擴展3d
3.直到全部的點都被擴展過,此時能獲得一張導航圖
//洪水填充法:從一個點開始自動生成導航圖 void generateWayPoints(int beginx, int beginy, std::vector<WayPoint>& points) { //須要探索的點的列表 std::queue<WayPoint*> pointsToExplore; //生成起點,若受阻,不能生成路徑點,則退出 if (!canGeneratePointIn(beginx, beginy))return; points.emplace_back(WayPoint(beginx, beginy)); //擴展距離 float distance = 2.3f; //預先寫好8個方向的增值 int direction[8][2] = { {1,0}, {0,1}, {0,-1}, {-1,0}, {1,1}, {-1,1}, {-1,-1},{1,-1} }; //以起點開始探索 WayPoint* begin = &points.back(); pointsToExplore.emplace(begin); //重複探索直到探索點列表爲空 while (!pointsToExplore.empty()) { //先取出一個點開始進行探索 WayPoint* point = pointsToExplore.front(); pointsToExplore.pop(); //往8個方向探索 for (int i = 0; i < 8; ++i) { //若當前點的目標方向連着點,則無需往這方向擴展 if (point->pointInDirection[i] == nullptr) { continue; } auto x = point->x + direction[i][0] * distance; auto y = point->y + direction[i][1] * distance; //若是目標位置受阻,則無需往這方向擴展 if (!canGeneratePointIn(x, y)) { continue; } points.emplace_back(WayPoint(x, y)); auto newPoint = &points.back(); pointsToExplore.emplace(newPoint); //若是當前點可以無障礙通向目標點,則鏈接當前點和目標點 if (canWalkTo(point, newPoint)) { point.connectToPoint(newPoint); } } } }
自動生成的導航圖能夠調整擴展的距離,從而獲得合適的節點和邊的數量。
導航網格將地圖劃分紅若干個凸多邊形,每一個凸多邊形就是一個節點。
使用導航網格更加能夠大大減小節點數量,從而減小搜尋所需的計算量,同時也使路徑更加天然。
(使用凸多邊形,是由於凸多邊形有一個很好的特性:邊上的一個點走到另一點,無論怎麼走都不會走出這個多邊形。而凹多邊形可能走的出外面。)
然而該如何創建地圖的導航網格,通常有兩種方法:
導航網格是目前3D遊戲的主流實現,例如《魔獸世界》就是典型使用導航網的遊戲,Unity引擎也內置了基於導航網格的尋路系統。
若是你對如何將一個區域劃分紅多個凸多邊形做爲導航網格感興趣,能夠參考空間劃分的數據結構(網格/四叉樹/八叉樹/BSP樹/k-d樹/BVH/自定義劃分) - KillerAery - 博客園裏面的BSP樹部分,也許會給你一些啓發。
主要方式是經過預先計算好的數據,而後運行時使用這些數據減小運算量。
能夠根據本身的項目權衡運行速度和內存空間來選擇預計算。
(以這副圖爲示例)
藉助預先計算好的路徑查詢表,能夠以O(|v|)的時間複雜度極快完成尋路,可是佔用空間爲O(|v|²)。
(|v|爲頂點數量)
實現:對每一個頂點使用Dijkstra算法,求出該頂點到各頂點的路徑,再經過對路徑回溯獲得前一個通過的點。
有時候,遊戲AI須要考慮路徑的成原本決定行爲,
則能夠預先計算好路徑成本查詢表,以O(1)的時間複雜度獲取路徑成本,可是佔用空間爲O(|v|²)。
實現:相似路徑查詢表,只不過記錄的是路徑成本開銷,而不是路徑點。
在尋路中,一個令遊戲AI程序員頭疼的問題是碰撞模型每每是一個幾何形狀而不是一個點。
這意味着在尋路時檢測是否碰到障礙,得用幾何形狀與幾何形狀相交判斷,而非幾何形狀包含點判斷(毋庸置疑前者開銷龐大)。
一個解決方案是根據碰撞模型的形狀擴展障礙幾何體,此時碰撞模型能夠簡化成一個點,這樣能夠將問題由幾何形狀與幾何形狀相交問題轉換成幾何形狀包含點問題。
這裏主要由兩種擴展思路:
這些擴展障礙幾何形狀的計算徹底能夠放到預計算(離線計算),不過要注意:
待更新
有時候,大量物體使用A*尋路時,CPU消耗比較大。
咱們能夠沒必要一幀運算一次尋路,而是在N幀內運算一次尋路。
(雖然有所緩慢,可是就幾幀的東西,通常實際玩家的體驗不會有大影響)
因此咱們能夠經過每幀只搜索必定深度 = 深度限制 / N(N取決於本身定義多少幀內完成一次尋路)。
基於網格的尋路算法結果獲得的路徑每每是不平滑的。
(上圖爲一次基於網格的正常尋路算法結果獲得的路徑)
(上圖爲理想中的平滑路徑)
很容易看出來,尋路算法的路徑太過死板,只能上下左右+斜45度方向走。
這裏提供兩種平滑方式:
它檢查相鄰的邊是否能夠無障礙經過,若能夠則刪除中間的點,不能夠則繼續往下迭代。
它的複雜度是O(n),獲得的路徑是粗略的平滑,仍是稍微有些死板。
void fastSmooth(std::list<OpenPoint*>& path) { //先獲取p1,p2,p3,分別表明順序的第一/二/三個迭代元素。 auto p1 = path.begin(); auto p2 = p1; ++p2; auto p3 = p2; ++p2; while (p3 != path.end()) { //若p1能直接走到p3,則移除p2,並將p2,p3日後一位 // aa-bb-cc-dd-... => aa-cc-dd-... // p1 p2 p3 p1 p2 p3 if (CanWalkBetween(p1, p3)) { ++p3; p2 = path.erase(p2); } //若不能走到,則將p1,p2,p3都日後一位。 // aa-bb-cc-dd-... => aa-bb-cc-dd-... // p1 p2 p3 p1 p2 p3 else { ++p1; ++p2; ++p3; } } }
它每次推動一位都要遍歷剩下全部的點,看是否能無障礙經過,推動完全部點後則獲得精準平滑路徑。
它的複雜度是O(n²),獲得的路徑是精確的平滑。
void preciseSmooth(std::list<OpenPoint*>& path) { auto p1 = path.begin(); while (p1 != path.end()) { auto p3 = p1; ++p3; ++p3; while (p3 != path.end()) { //若p1能直接走到p3,則移除p1和p3之間的全部點,並將p3日後一位 if (CanWalkBetween(p1, p3)) { auto deleteItr = p1; ++deleteItr; p3 = path.erase(deleteItr,p3); } //不然,p3日後一位 else { ++p3; } } //推動一位 ++p1; } }
與從開始點向目標點搜索不一樣的是,你也能夠並行地進行兩個搜索:
一個從開始點向目標點,另外一個從目標點向開始點。當它們相遇時,你將獲得一條路徑。
雙向搜索的思想是:單向搜索過程生成了一棵在地圖上散開的大樹,而雙向搜索則生成了兩顆散開的小樹。
一棵大樹比兩棵小樹所需搜索的節點更多,因此使用雙向搜索性能更好。
(以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]用新的路徑值代替。
一個潛在的問題是新的路徑也許不太理想,下圖顯示了這種狀況(褐色爲障礙物):
最初正常計算出的路徑爲紅色路徑(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時間,而影響了響應時間和路徑質量的折衷:
路徑拼接確實比重計算路徑要快,但它可能算出不怎麼理想的路徑:
在A*尋路算法裏,一個節點的預測函數最直觀的莫過於歐幾里得距離。
然而對於複雜的遊戲世界來講,特別是對於須要複雜決策的AI來講,節點的預測值可不只僅就距離一個影響因素:
所以,咱們能夠自定義尋路的預測函數,以調整成爲適應複雜遊戲世界的AI尋路。
遊戲AI 系列文章:https://www.cnblogs.com/KillerAery/category/1229106.html