遊戲服務器AOI的實現


在一個場景裏,怪物A攻擊了玩家B,玩家B掉了5血量。玩家B反擊,怪物A掉了10血量。玩家C在旁邊觀看了這一過程,而在遠處的玩家D對這一過程毫無所知。這是MMO遊戲中很常見的一情景,從程序邏輯的角度來看,把它拆分成以下幾部分

  1. 怪物A感知玩家B在攻擊距離內,釋放了技能,並把整個過程廣播給附近的玩家B、玩家C
  2. 玩家B感知怪物A在攻擊距離內,釋放了技能進行反擊,並把整個過程廣播給自己(玩家B)、附近的玩家C
  3. 玩家D因爲離得太遠,無法感知這個過程

可以看到,整個邏輯都是以位置爲基礎來進行的,玩家需要知道周邊發生了什麼。通常把玩家周邊的這塊區域叫做玩家感興趣的區域,即AOI(Area Of Interest),其大小即玩家的視野大小,上圖畫出了C、D這兩個玩家的AOI。玩家AOI區域裏的視覺變化(如攻擊、掉血、移動、變身、換裝等等),都需要通知玩家。而不在區域內的變化,比如上面的玩家D的AOI不包含A、B,就不需要通知他。怪物是不需要知道這些視覺變化的,因此一般來說怪物是沒有AOI的。

AOI的核心是位置管理,其作用一是根據AOI優化數據發送(離得太遠的玩家不需要發送數據,減少通信量),二是爲位置相關的操作提供支持(例如玩家一個技能打出去,需要知道自己周圍有哪些怪物、玩家,這些都是通過AOI來查詢)。

PS: 場景中的玩家、怪物、NPC等統稱爲實體,下面有用到。

Interest列表

AOI的作用之一是優化數據發送,哪到底這個要怎麼實現呢。以上面的情景爲例,怪物A攻擊時,是如何知道要把數據發送給B、C,而不發給D呢?最簡單的辦法是把場景裏的玩家遍歷一次,計算一下位置。但在實際中,一次攻擊可能會下發4到5個數據包(攻擊、掉血同步、怪物死亡、擊退等等),現在有些遊戲喜歡做成一刀打一片怪,那數據包可能要到10個以上了,每次都計算一下顯然是不太現實的。因此一般每個實體上都有一個列表,所有對該實體感興趣的玩家(即AOI包含該實體的玩家),都在列表上,一般把這個列表叫做Interest列表,或者觀察者列表、目擊者列表。例如,C在A、B的Interest列表裏,D不在,所以A、B攻擊時,把數據發給了玩家C,沒發給D。

每當位置變化時,需要維護這個列表,這個處理起來還挺麻煩,後面再細說。

AOI區域的形狀與大小

理想情況下,AOI區域是圓形的,因爲現實生活中人在各個方向的視野大小都是一樣的。不過用來玩遊戲的手機、顯示器可不是圓形的,因此爲了方便,很多時候AOI是做成了方形的。一來AOI區域的大小並不需要很嚴格,大點小點一般沒問題,二是判斷點是否在圓內,需要計算平方,而判斷是否在正方形內,只需要判斷大小,效率高一些。還有另一個原因就是有些AOI算法,不太好實現圓形區域(如下面的格子算法)。

雖然實體看得比較遠,例如玩家可以看到很遠的那座山。但很多遊戲不會給你拉那麼遠的鏡頭的機會(看到的遠處的山實際是裝飾用的,走不到那個位置,和AOI無關),所以不少遊戲的AOI都很小,只有幾個格子,等同手機屏幕大小即可。折算到現實現生活中大概只有10多米,即只能看到旁邊的那塊石頭。

AOI算法

AOI並沒有什麼特別優秀又通用的算法,甚至做一些同場景人數不多的遊戲時(比如經典的傳奇類遊戲),簡單的遍歷或者全場景廣播都比其他算法優秀。其他算法是各有各的特點,下面簡單說下一些通用的AOI算法

  • 九宮格

    如圖所示,九宮格AOI算法核心是把整個地圖劃分成大小相等的正方形格子,每個格子用一個數組存儲在格子裏的玩家,玩家的視野即上圖中標了數字的九個格子(如果視野大小爲2個格子,再往外擴一圈即可,依此類推)。九宮格的優點是效率高,拿到座標後即可跳轉到對應的格子,視野範圍內需要遍歷的格子也不多,配合經典的格子地圖(tile map)再合適不過,都不需要把像素座標轉格子座標。其缺點是佔用內存有點大,因爲必須爲所有格子預留一個數組,即使是一個數組指針,長寬爲1024的一個地圖也要1024 * 1024 * 8 = 8M內存,這還不算真正要存數據的結構,僅僅是必須預留的。

我實現了一個格子的AOI算法用於測試:https://github.com/changnet/MServer/blob/master/engine/src/scene/grid_aoi.hpp

  • 燈塔
    燈塔AOI是把整個地圖劃分成比較大的格子,每個格子稱爲一個燈塔,玩家視野一般涉及上下左右4個燈塔(之所以不是周邊的9個而是4個是因爲燈塔必須大於玩家的視野,因此偏向左下方就查左下方那4個格子即可,不用查9個,其他依此類推)。我覺得這個算法和九宮格沒啥區別,無非就是格子變大了些,九宮格變成了四宮格,因此我沒有實現這個算法。網易的pomelo有實現這個算法,可以參考一下。

  • 十字鏈表
    把場景中的實體按位置從小到大用雙向鏈表保存起來,X軸用一條雙向鏈表,Y軸用一條雙向鏈表,因爲在畫座標時X軸和Y軸剛好呈十字,所以稱十字鏈表(嗯,我覺得是這樣,但找不到出處)。但是查資料的時候我發現,這個算法的實現幾乎按55比例分成了兩種

  1. 鏈表中保存的是一個點
    每個實體在鏈表中爲一個節點,如a->b->c->d->f

  2. 鏈表中保存的是一條線段
    每個實體在鏈表中爲一個線段,包含(左視野邊界AL、實體本身A、右視野邊界AR)三個點,如下圖

我不太理解第一種的算法,因爲插入、移動實體時,都需要從其中一條鏈表當前實體分別向兩邊遍歷到視野邊界,才能維護interest列表,查找視野範圍內的實體也是如此。既然是隻遍歷其中一條鏈表,爲啥需要兩條鏈表,只用X軸一條鏈表即可。有人認爲需要兩條鏈表是因爲查找視野內實體是需要分別遍歷x、y兩軸,再求兩軸的交集。我覺得遍歷x軸,判斷每個實體是否在視野內比求交集高效。

而對於第二種算法,每個實體爲一條線段,線段起點爲左視野邊界,終點爲右視野邊界,中間還得加上實體本身,如上圖中實體A爲AL、A、AR,實體B爲BL、B、BR。當插入、移動實體時,如果已方邊界遇到對方實體,則表示對方進入或退出自己視野,如果對方邊界遇到己方實體,則表示自己進入或退出對方視野。例如上圖中,A在BL與BR之間,則表示A在B的視野範圍內,而B不在AL與AR之間,則B不在A的視野範圍內。當然,像怪物、NPC這種沒有視野的實體,就可以優化成只需要一個點,按第一種算法處理。

算法二的實現比較複雜,其優點是移動的時候,遍歷的數量比較少。例如:實體從(1, 100)移動到(1, 101),必須找出視野範圍內的玩家。對於算法一,沒有什麼變量能確定是遍歷X軸還是遍歷Y軸,因此只能隨意選擇一個。假如選擇X軸,極端情況下,場景所有實體X座標都在1,但Y軸都不一樣,但這種算法就變成了遍歷所有實體。對於算法二,由於X軸不變,因此X軸不需要移動,把Y軸向右移動1,在移動的過程中,根據“如果已方邊界遇到對方實體,則表示對方進入或退出自己視野,如果對方邊界遇到己方實體,則表示自己進入或退出對方視野”這個規則來處理遍歷的實體即可。

但我的疑問是,算法二會導致鏈表長度大增加,其插入、移除的複雜度都高於算法一,僅僅是移動所帶來的好處能抵消嗎?
目前我用算法二實現了一個十字鏈表https://github.com/changnet/MServer/blob/master/engine/src/scene/list_aoi.hpp

另外,十字鏈表這算法都是很怕聚集的,例如大部分實體的X座標都在2,另一個實體從1移到3就需要遍歷大量的實體了。

  • 四叉樹
    AOI的核心是對空間進行管理,格子太耗內存,鏈表遍歷太耗CPU,那四叉樹是一個比較合適的方案。四叉樹是把地圖分成4塊,每一塊裏再分4小塊,根據場景中實體的數量不斷地遞歸劃分直到最小值(比如一個實體的視野範圍)。盜用別人的圖演示一下

    假如一個實體的座標在L區域,那麼需要從A->H->L這條路線來查詢,遍歷也不算太多。但是這個算法有一個缺點,就是視野不好處理,沒法直接搜索相鄰的實體。假如上圖中的L區域右邊爲B區域,但是在四叉樹查詢B區域的實體是走B->?的,和L區域的完全不一樣。

由於我對四叉樹不太熟悉,也沒在實際項目中用過,因此不太清楚一些具體的細節是怎麼處理的,暫時沒有實現。不過別人實現了一個,可以參考一下。

  • 跳錶
    我原本並沒有考慮這個算法,但在對比九宮格和十字鏈表的性能後,我對自己實現的十字鏈表性能很不滿意,但是九宮格效率雖高,卻不適合大地圖、可變視野、三軸座標,說到底還是沒有實現一種比較通用高效的算法,心有不甘。用callgrind看了十字鏈表半天后,CPU都耗在鏈表的遍歷、插入、移動,因爲它的鏈表實在太長了,而且有三條鏈表,最終沒有找到什麼辦法來優化,放棄了。九宮格如果改用unordered map,性能會下降一些,加上三軸,需要遍歷的格子多了,再降一些,實現可變視野後,繼續再降一點,這麼多缺點我連嘗試的動力都沒有了。而我到現在也沒想明白四叉樹是怎麼搜索相鄰的實體,如果非得從樹根遍歷,再加上三軸和可變視野,那我覺得性能不會太好看。

於是我打算實現單鏈表(類似十字鏈表的第一種算法,但沒了y軸鏈表),對比一下是否會有更好的表現。不過單鏈表很明顯的一個問題就是插入太慢,於是我打算加上索引。鏈表加上索引,那不就是跳錶麼。

參考別人的blog

從上圖中可以看到,跳錶需要在鏈表中加上多層索引,然後根據索引跳躍式搜索。不過我覺得對於AOI來說,多層索引過於複雜,維護這些索引費時費力。那就用一層?用一層的話遍歷索引也很費力,效率提升不大。鏈表節點變化時,還得更新索引,麻煩。

後來想用多鏈表來實現,即像九宮格那樣,把x軸平均分段,每段是一個鏈表,用數組管理,訪問時直接用x/index計算出數組下標。但是這樣的話要查詢相鄰的實體可能要查詢兩條鏈表,而且實體移動需要跨鏈表時也需要額外的處理。

這裏我忽然想到,我爲啥不用靜態索引跳錶呢?對於一個通用的跳錶而言,它存什麼數據是未知的,數據的分佈是未知的,它的索引理想的情況應該是平均分佈的,這樣查找的時候效率才高,因此需要維護索引。但對於AOI而言,它存的就是座標,而且創建AOI的時候,肯定是知道地圖的大小的。把x軸平均分段,每一段起點插入一個特殊的節點當作索引,然後用數組管理索引,訪問時直接用x/index計算出數組下標。

紅色爲固定的索引節點(索引分段爲1000),在創建AOI時就建立好,然後存到一個數組裏。插入實體A(X=2200)時,2200/1000=2,所以直接取索引節點2(索引從0開始)開始搜索合適的位置。

和原生的跳錶相比,這種實現簡單而且搜索效率高,不用維護索引。缺點是當實體聚集(比如所有實體座標都在[0,1000])時索引命中非常低。

可變視野與飛行、跳躍

絕大多數MMO遊戲,尤其是武俠類的遊戲,基本上都所有實體的視野都一樣的。不過隨着一些跳躍、飛行玩法的加入,飛行中或者跳到高處的玩家,視野更大。九宮格、燈塔之類的算法其實不太適合做這個。例如九宮格原本只需要遍歷九個格子,假如有了可變視野,那隻能按最大視野範圍遍歷,那就不止九個了,而絕大部分玩家的視野都是9個格子,徒增一些無效的遍歷。

而用鏈表實現的AOI,視野變化只是遍歷鏈表長度不一樣,對現在的邏輯沒有任何影響,都不需要改任何代碼。

三軸AOI

越來越多的遊戲開始使用3D地形,不過一般來說,地形對於武俠類遊戲的服務器幾乎沒有影響,依然可以使用二軸AOI。一般是忽略高度,在高處的玩家和低處的玩家對於服務端來說是一樣的,如果技能釋放的時候有要求,那特殊處理一下也行。比如TrinityCore使用的是三座標,但對於AOI來說只有二座標。

當然想要做得細緻一點也是可以的。九宮格需要多出一條軸,就變成27宮格了,而一張長寬高均爲1024個格子的地圖預留的內存就變成1024 * 1024 * 1024 * 8 = 8G。當然沒人會給一張地圖分這麼多內存,可以考慮用unordered map,只是會慢一點而已。而十字鏈表,也需要多加一條鏈表。我上面實現的十字鏈表就是三軸可變視野的,而九宮格實現三軸的,我還沒見過。

AOI的實現方式

有些項目做AOI時,是在AOI裏定時去更新同步位置的。即更新位置時,不通知前端,而是在定時器裏定更新位置,同步到前端。這種方式可能會更省一些資源,但極限情況下就需要特殊處理。例如釋放技能時,把遠處的玩家勾過來,再一腳踢飛出去,如果用定時器,那這個位置變化過程可能就沒有同步到前端。當然特殊的問題可以特殊處理,這個可以手動同步一次,或者在技能那邊處理即可。

有些甚至以一個獨立的進程去實現的。即實體有變化時,通知另一個進程,由該進程定時同步位置到前端,雲風討論AOI模塊時便是這個思路。從位置同步這一塊來講,這是沒問題的。但是一般來說AOI兼顧技能的位置查詢,以及一些外顯數據的同步,不知道他們是怎麼處理的。

另一種方式是AOI做實時,更新玩家位置時,立刻更新AOI中的位置,並同步到前端。而像移動這種,不是在AOI中做的,而是由定時器根據玩家移動速度定時計算出新位置,同步到AOI中。

我更趨向於第二種的,因爲可以控制得更加細緻,所以AOI是寫成一個庫。而採用第一種方式的,往往是把AOI直接寫成一個獨立的進程(或微服務之類的)。當然有了一個庫,把它封裝成一個微服務的也不算太難。

性能

別人的實現,因爲接口、語言都不一樣,因此我是沒法測試的,不過我自己寫的,可以對比一下
CPU: AMD A8-4500M [email protected]
OS: debian 10@VirtualBox

[T0LP01-24 13:49:21]Using filter: aoi
[T0LP01-24 13:49:21]test grid aoi
[T0LP01-24 13:50:51][  OK] base test (89210ms)
[T0LP01-24 13:50:55][  OK] perf test 2000 entity and 50000 times random move/exit/enter (3902ms)
[T0LP01-24 13:51:01]actually run 1767
[T0LP01-24 13:51:01][  OK] query visual test 2000 entity and 1000 times visual range (5980ms)
[T0LP01-24 13:51:01]list aoi test
[T0LP01-24 13:51:01][  OK] list_aoi_bug
[T0LP01-24 13:51:23][  OK] base list aoi test (21999ms)
[T0LP01-24 13:51:29][  OK] perf test no_y(more index) 2000 entity and 50000 times random M/E/E (6174ms)
[T0LP01-24 13:51:41][  OK] perf test 1 index 2000 entity and 50000 times random move/exit/enter (11683ms)
[T0LP01-24 13:51:46][  OK] perf test 2000 entity and 50000 times random move/exit/enter (5153ms)
[T0LP01-24 13:52:11]actually run 1978
[T0LP01-24 13:52:11][  OK] query visual test 2000 entity and 1000 times visual range (24737ms)
[T0LP01-24 13:53:42]actually run 674000
[T0LP01-24 13:53:42][  OK] change visual test 2000 entity and 1000 times visual range (90775ms)

[T0LP01-24 13:51:01]list aoi test
[T0LP01-24 15:32:15][  OK] list_aoi_bug (2ms)
[T0LP01-24 15:33:07][  OK] base list aoi test (52175ms)
[T0LP01-24 15:33:19][  OK] perf test no_y(more index) 2000 entity and 50000 times random M/E/E (11598ms)
[T0LP01-24 15:33:33][  OK] perf test 1 index 2000 entity and 50000 times random move/exit/enter (14237ms)
[T0LP01-24 15:33:48][  OK] perf test 2000 entity and 50000 times random move/exit/enter (14483ms)
[T0LP01-24 15:34:06]actually run 1952
[T0LP01-24 15:34:06][  OK] query visual test 2000 entity and 1000 times visual range (17719ms)
[T0LP01-24 15:34:49]actually run 634000
[T0LP01-24 15:34:49][  OK] change visual test 2000 entity and 1000 times visual range (43290ms)

  • 九宮格
    地圖X最大6400,Y最大12800,格子邊長64,視野半寬3 * 64,視野半高4 * 64,即這裏實現的不是九宮格,而是視野寬高不對等的格子。2000玩家、怪物、NPC隨機進入地圖,然後隨機執行50000次退出、進入、移動,耗時3902ms,最終場景裏還剩下1767個實體,對每個實體執行1000次查詢視野範圍內的實體,耗時5980ms

  • 跳錶(固定索引)
    地圖X最大6400,Y最大19200,Z最大12800,視野半徑256。2000玩家、怪物、NPC隨機進入地圖,然後隨機執行50000次退出、進入、移動,耗時6174ms,最終場景裏還剩下1978個實體,對每個實體執行1000次查詢視野範圍內的實體,耗時13243ms

當只採用一個索引時,這個就退化成單鏈表,我測了下,隨機執行50000次退出、進入、移動,耗時11683ms,如果測多次,還是略好於十字鏈表的。

  • 十字鏈表
    地圖X最大6400,Y最大19200,Z最大12800,視野半徑256。2000玩家、怪物、NPC隨機進入地圖,然後隨機執行50000次退出、進入、移動,耗時10656ms,最終場景裏還剩下1967個實體,對每個實體執行1000次查詢視野範圍內的實體,耗時17719ms

可以看到十字鏈表的性能並不是很理想,雖然算下來單個實體的單次操作(移動、進入、退出、視野變化)都在1ms以下,但是相對於九宮格還是太慢了,只能說夠用。用跳錶實現的介於兩者之間,即支持三軸,也支持可變視野,性能又不太差,算是一個比較通用的AOI。

另外,這些測試數據有些異常,例如跳錶的可變視野耗時基本是高於十字鏈表的,但從邏輯來看它們應該是差不多的,估計哪裏有bug,但又沒找到證據。

其他方案

      __    __    __
     /  \__/  \__/  \
     \__/  \__/  \__/
     /  \__/  \__/  \
     \__/  \__/  \__/

雲風用六邊形做了一個燈塔AOI,相比四邊形的燈塔只需要查詢3個燈塔(燈塔設計得比視野大,雖然被7個燈塔包圍,但是偏向哪邊就查對應那邊的3個燈塔即可)。不過我覺得多邊形的運算太過於複雜(假如實體進入AOI時,需要判斷屬於哪個多邊形,這個比燈塔、九宮格複雜。而且,這個要怎麼實現三軸啊)。

  • kbengine
    kbengine的AOI是三軸十字鏈表,支持可變視野。在查資料的時候,看過他的實現,這裏記錄一下。

CoordinateSystems是AOI的主核心,三條鏈表都放這個類裏。CoordinateNode是鏈表節點的基類,EntityCoordinateNode是實體在鏈表中的節點,RangeTriggerNode是視野左右邊界在鏈表中的節點,通過COORDINATE_NODE_FLAG_POSITIVE_BOUNDARYCOORDINATE_NODE_FLAG_NEGATIVE_BOUNDARY這個flag來區分。

實體進入場景時,走Entity::installCoordinateNodes -- CoordinateSystems::insert把實體插入鏈表。接着初始化實體的視野會調用Witness::setViewRadius,這裏會創建ViewTrigger並分別把左右視野邊界插入鏈表。

當新節點插入或者位置有變化時,都會通過CoordinateSystem::update -- coordinateSystem::moveNodeX -- RangeTrigger::onNodePassX 調整鏈表中的節點,onNodePassX是一個多態函數,不同類型的CoordinateNode做不同的處理,觸發實體進入、離開視野。

總體看下來,這個AOI運算量還是挺大的。這個模塊沒有單獨出來,也沒法直接放到我的代碼裏一同測試,性能如何不太清楚。

  • 其他
    AOI的實現在英文資料非常少,想參考一下都不行。只搜索到一篇論文,測試了各種奇奇怪怪的AOI算法


    但是這看起來並沒有什麼實際應用價值。唯一看到過真實應用的是TrinityCore,這個只是用了一個九宮格的AOI。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章