本文是探索如何制作快節奏多人遊戲相關技術和算法的系列文章中的第一章。如果你熟悉多人遊戲背後的概念,可以放心跳過本章-接下來是一些介紹性的討論。
作弊問題
一切都始於作弊。
做為遊戲開發者,通常不會關心是否有人在你的單人遊戲中作弊,因為他的行為隻會影響他自個兒。作弊的玩傢可能不會按照你設計的過程來體驗遊戲,但這已經是他自己的遊戲,他們有權利想怎麼玩就怎麼玩。
多人遊戲則不同。在所有競技遊戲中,作弊玩傢不僅隻提升自己的體驗,同時也破壞瞭其它玩傢的體驗。做為開發者你得避免這種行為,因為這將導致玩傢離開你的作品。
有許多辦法能防止作弊,但最重要的一點(也可能是唯一真正有意義的一點)很簡單:不信任玩傢。做好最壞的打算-玩傢會嘗試作弊。
權威服務器和啞客戶端(Authoritative servers and dumb clients)
這是一個看似簡單的解決方案-所有的遊戲邏輯在你的服務端實現,客戶端僅做遊戲表現。換句話說,就是遊戲客戶端向服務器上送輸入(玩傢的按鍵,指令),服務端運行遊戲邏輯並下發運行結果給客戶端。這種做法可稱之為“權威服務器(authoritativeserver)”,因為遊戲中發生的一切都由服務器控制。
當然,你的遊戲服務端可能有能被利用的漏洞,那超出瞭我們的討論范圍。但使用權威服務器這種模式能防止大部分的攻擊。比如,玩傢的血量以服務器的為準,被攻擊的客戶端能在本地將玩傢的血量改大100倍,但服務器那還是隻剩10%的血量-當玩傢被攻擊時還是會死掉,客戶端怎麼改也沒有用。
玩傢遊戲中的位置信息也不能相信客戶端。否則,被攻擊的客戶端通過上報位置給服務器說“我在(10,10)”並在一秒後上報“我在(20,10),達到穿墻或行動快於其它玩傢的目的。相反,服務器知道玩傢的位置在(10,10),客戶端上報指令說“向右移動一格”,服務器運行更新內部狀態計算出玩傢新的位置在(11,10),並返回客戶端“你現在在(11,10)”:
總而言之:遊戲狀態完全由服務端管理。客戶端上送操作給服務器,服務器定期更新遊戲狀態,並下發新的遊戲狀態給客戶端進行表現渲染。
網絡處理
上述啞客戶端(dumb client)的方案在慢節奏的回合制遊戲上工作得很好,如策略或者是牌類遊戲。在局域網、或是無延遲的通訊環境下也運行良好。但用於快節奏網絡遊戲特別是在互聯網環境下就完蛋瞭。
先說物理環境。設想你在舊金山,連到位於紐約的服務器,大概4000公裡或者2500英裡(大概是兩倍北京到香港的距離)。字節在Internet上傳輸接近光速(達到低級別的光脈沖、電纜中的電子,或電磁波的速度),光速大概30萬公裡/秒,所以這個距離大概需要13毫秒。
這聽起來非常快,而且是非常樂觀的設置-假設瞭數據能以光速直線傳輸,其實不然。在實際情況下,數據需要從路由器到路由器之間通過一系列的中轉(網絡術語稱之為hops),達不到光速;因為數據包必須復制、檢查、重新路由,所以路由器本身會造成一些延遲。
為求說法,我們假設數據在客戶端到服務器耗時50毫秒,這比較接近最佳場景。如果你從紐約接入位於東京的服務器呢?如果出於某種原因導致網絡擁塞?延遲100,200甚至500甚至更大。
回到我們的例子,你的客戶端上送指令給服務器說“我按瞭右方向鍵”,服務器在50毫秒後收到,如果服務器立即處理完請求更新狀態並回應,客戶端也得在50毫秒後收到新的遊戲狀態“你現在在(1,0)”。
這樣看來,你按下右方向鍵後有一小會沒有任何效果,然後遊戲角色才會右移一格。這種處於你的輸入指令和響應之間的卡頓不多,但非常明顯。當然,半秒的卡頓已經不是明顯,會直接導致遊戲沒法玩。
總結
多人網絡遊戲如此好玩,但面臨全新的挑戰。權威服務器的架構能很好的防止作弊,但僅僅這樣簡單實現將造成遊戲響應遲鈍。
後續內容我們將嘗試基於權威服務器架構來構建一個系統,讓玩傢得到最小的延遲的體驗,達到幾乎和本地單機遊戲一樣的效果。
快節奏多人遊戲:客戶端預測+服務器比對
前言
在本系列的第一章中,我們探討過一種權威服務器與啞客戶端的C/S模型:僅上送輸入指令到服務器,然後在服務器更新遊戲狀態並在響應之後由客戶端展現效果。
此原始實現方案會導致玩傢操作到屏幕響應之間的延遲:玩傢按下右方向鍵,遊戲角色過一秒才開始移動。這是因為客戶端輸入必須先傳輸到服務器,服務器必須處理輸入並計算出新的遊戲狀態,然後新的遊戲狀態才能回應給客戶端表現。
在Internet這樣的網絡環境中,如果延遲達到十分之一秒,遊戲操作就會感覺反應遲鈍,最糟的情況則沒法玩兒。在這部分內容中,我們得尋求改善並消除這個問題的方法。
客戶端預測
盡管會存在一些作弊玩傢,但大多數時間裡遊戲服務器處理的是有效的請求。這意味著遊戲狀態會按照預期更新;比如在你按瞭右方向鍵後角色就會走到(11,10)這個位置。
我們可以利用這一特點,在遊戲世界是足夠的可預測的情況下(即,給予的遊戲狀態和一系列輸入,運行結果是完全可預測的)。假設存在著100毫秒的滯後,並且遊戲角色移動一個格子的動畫也需要100毫秒和話。使用之前的實現,整個動作得花200毫秒。
由於遊戲世界是確定性的,我們可以假設玩傢上送到服務器的指命都能成功執行。基於這個假設,客戶端可以預測指令在處理之後遊戲世界的狀態,而且預測幾乎能完全正確。
相比上送指令然後等待新的遊戲狀態再開始表現,現在客戶端可以在上送指令後就立即展現效果,如果上送的指令正確處理那麼等待得到的新遊戲狀態將與客戶端本地計算的狀態相一致。
這樣下來,使用權威服務器模式,玩傢操作與屏幕表現的效果將完全沒有延遲(如果被攻擊的客戶端發出無效的指令,也能看到相應的效果,但不會影響服務器的狀態和其他玩傢的表現)。
同步問題
上面的例子裡,我專門選的數值讓遊戲運行得非常好。但是,如果稍微改動一下場景:服務器響應延遲達到250毫秒,移動一格動畫消耗100毫秒,玩傢連續按瞭兩次右方向鍵,想向右移動兩格。
繼續使用上述方法後,會是這個樣子:
問題開始變得有趣。收到遊戲新狀態的時間t=250ms,而客戶端此時預測產生的狀態是x=12,但服務器返回的狀態卻是x=11。因為服務器權威,這樣客戶端必須把角色退回到x=11的位置。但此時,在t=350時又收到瞭服務器更新狀態,並通知說x=12,因為角色這時又得跳回去。
以玩傢的角度看,他按瞭兩次右方向鍵,角色向右移動瞭兩格,站在那50毫秒後又跳回左邊一格,停瞭100毫秒後再次跳到右邊。這顯然是無法接受的。
服務器校對
解決此問題的關鍵在於理解客戶端看到的遊戲世界是現在時,但由於滯後,從服務端取得的更新實際上是過去時的狀態。服務器下發的遊戲更新狀態,還不是處理完所有客戶端上送指令後的狀態。
解決這個問題不難。首先,客戶端在每個請求中增加序列號;上例中,第一個按鍵請求序列號為#1,第二次按鍵為請求#2。然後服務器的回應也帶上相應的序號:
這樣的話,當t=250,服務端說“按請求#1,你的位置在x=11”,然後將服務器中角色位置設置為x=11。現在假設客戶端留有發往服務器請求包的所有備份,按照收到的回應,客戶端得知服務器處理完請求#1,所以扔掉本地對應的拷貝,客戶端知道後續還有#2的回應,所以本地預測會繼續。即使有些請求服務器還沒處理到,客戶端仍能根據服務端最後下發的狀態計算出遊戲的“當前”狀態。
因此,當t=250,客戶端收到“x=11,已處理請求#1”,就扔掉請求包備份中的#1,但保留服務端尚未確認的#2的備份,然後將遊戲內部狀態按服務器的響應設置為x=11,並且會繼續表現服務器尚未收到的所有請求,即“向右移”的#2指令,得到的最終正確結果x=12。
之後,當在t=350時收到服務端的遊戲新狀態時,服務端指示“x=12,已處理請求#2”,這時客戶端扔掉#2指令備份,並更新狀態為x=12。此時備份中已經沒有未處理的指令,所以客戶端處理以正確的結果停在這兒。
其它
上面討論的是移動的處理,但同樣的原理適用於其它所有內容。比如回合制戰鬥遊戲,當玩傢攻擊一個其它角色時,可以展現掉血和傷害數值,但在服務器回應之前還不應該更新那個角色的生命值。
因為遊戲狀態的復雜性,不是總能簡單的應對,可能你得在收到服務器確認前避免一個角色被殺掉,哪怕當前客戶端狀態中它的生命值低於0,比如這個角色正好在你的致命攻擊之前使用瞭回血包,但服務器還沒下發給你。
這又引入一個有趣的問題,即便遊戲世界是完全確定的並且沒有任何客戶端作弊,還是有可能出現客戶端預測的狀態與服務端下發的狀態在比對後不一致。這個情形在單人遊戲不可能出現,但在服務器上同時連接瞭多個玩傢時很常見。這將是下一篇文章的主題。
總結
使用權威服務器,需要在客戶端等待服務器實際處理上送指令的過程中,給予玩傢即時響應的錯覺,客戶端按玩傢的操作模擬出效果,當收到服務端的新狀態時,客戶端會使用收到的更新和其後上送服務器但還沒確認的輸入重算之前已預算的狀態。
實體插值
簡介
在本系列第一章,討論的是權威服務器的概念及其防作弊的作用,但過於簡單得使用這個方案會帶來可玩性和響應能力相關的問題;到第二章中則提到運用客戶端預測來克服這些問題。
這兩篇文章的最終結論是一系列關於讓一個玩傢在互聯網有傳輸延遲的環境下連接權威服務器的情況下,操作遊戲角色達到單機遊戲體驗的概念和技巧。
在本章,我們討論在相同的服務器有多個玩傢控制的角色接入的情況。
服務器時步(Server time step)
上一章,我們把服務器的行為描述的非常簡單:讀取客戶端指令,更新遊戲狀態,並回應給客戶端。但當多個客戶端接入時,服務器的主循環邏輯會略有不同。
此時,在快節奏遊戲中的多個客戶端可能會同時上送指令(玩傢飛快的操作按鍵,鼠標移動和點擊來發出指令)。每次從每個客戶端收到上送指令都處理遊戲世界的狀更新和廣播的話,將消耗大量CPU和帶寬。
將收到的客戶端指令不做處理立即放到隊列中,然後以較低頻率定期處理遊戲世界的更新(比如每秒更新10次)是個好辦法。這樣的話,每次更新的延遲是100毫秒,我們稱之為時步(timestep)。服務器在每次循環更新時,處理掉所有未處理的客戶端指令(可能要以比時步更小的時間增量處理,方便預測物理行為),然後將新的遊戲狀態廣播給客戶端。
總之,遊戲世界的更新隻以預期的頻率進行,而與客戶端指令的上送和數量無關。
處理低頻更新
從客戶端來看,這種方式和之前一樣能順暢的運行:客戶端預測處理與更新間隔的延遲無關,所以在相對較少的狀態更新時也能清晰的預測處理。但由於遊戲狀態廣播的頻率低(如上每100毫秒一次),那麼客戶端隻有遊戲世界中非常少量的可到處移動的那些其它實體的信息。
之前第一種實現會在收到新狀態時更新其它玩傢角色的位置,那這些角色原本平滑的移動變成瞭每100毫秒分散的移動,成瞭非常斷斷續續的瞬移。如下圖:
根據遊戲類型相應有各種針對此問題的解決方案;通常情況下,遊戲中的實體越可預測就越好解決。
航位推算(Dead reckoning)
設想我們開發一款賽車遊戲,車速非常快所以很好預測,比如賽車的速度每秒100米,一秒之後它將位於起點後大致100米的位置。為什麼是“大致”?在一秒之間,車子可以加速或減速瞭一點兒,或者是左轉或右轉瞭一點兒,註意這個詞“一點兒”,汽車的機動性就是這樣,因為無論玩傢如何實際操作,賽車在高速行駛期間其時間點所在的位置很大程度取決於其之前的位置、速度和方向。換句話說,賽車不會立即出現180度轉向。
那當服務器每100毫秒下發一次更新的話如何處理?客戶端收到下發的每臺競技賽車的速度和朝向,要下個100毫秒後才收到新的信息,仍得展現賽車繼續在行駛。最簡單的做法就是假設100毫秒內車速和朝向不變,然後通過這些參數本地運行賽車的物理表現。然後在100毫秒收到服務器更新後再修正車的位置。
修正處理可大也可小,取決於很多因素。如果玩傢保持賽車直線前進並且沒有改變車速,那麼預測的位置將精確的對應服務器通知要修正的位置。另一方面,如果玩傢被什麼東西撞毀,那預測的位置也會錯得離譜。
註意航位推算法更適用於低速場景,比如戰艦。實際上,“航位推算”就是起源於海上航行。
實體插值
有不少場景無法應用航位推算法:特別是當玩傢方向和速度會隨時改變的情景下。例如3D射擊遊戲,玩傢們通常會高速的跑動、停止,轉彎,這時航位法幾乎無效,因為位置和速度不再能通過之前的數據進行預測。
收到服務器為準的數據時不能立即更新玩傢的位置,這會讓玩傢每100毫秒閃跳一段距離而使得遊戲沒法玩。
那在每100毫秒得到服務端位置數據時該怎麼做呢?訣竅在於在這期間如何展現玩傢角色的動畫。答案的關鍵是以相對於玩傢過去的狀態來展現其它玩傢。
比如在t=1000這個時間點收到位置數據時,已經有瞭時間點t=900的數據,所以可以知道玩傢在t=900和t=1000時的位置,因此,從t=1000和t=1100,展現的是其它玩傢在t=900到t=1000時的行為狀態。這樣的話總是以玩傢真實的移動數據進行展現,隻是滯後瞭100毫秒。
從t=900到t=1000的用於插值的位置數據取決於遊戲。插值處理通常效果良好,否則也可以讓服務器在每次更新時下發更多詳細的行動數據來改善插值效果,比如玩傢路線的一系列直線段或者是每10毫秒的位置采樣(隻需要發送小的行動的增量數據就不會放大下發的數據量,因為這種情況在數據傳輸時會被大量優化)。
需要註意,使用這種方式時,每個玩傢看到的遊戲世界的表現會稍微不同,因為每個玩傢看到的自己是現在時但看到的其它實體是過去時,但是就算在快節奏遊戲中看其它實體有100毫秒的差異感覺也不明顯。
也有例外:當處於要求大量空間和時間精度的場景中,如當某玩傢槍擊其他玩傢時,因為看到的其他玩傢是過去時,瞄準有100毫秒滯後,這表示你正在射擊的是100毫秒之前的目標!我們會在下一章處理這個問題。
總結
在有網絡延遲並使用低頻率更新的權威服務器的C/S架構中,仍然必須為玩傢提供遊戲的連續性和平滑運行的錯覺。在本系列第二部分中討論過通過客戶端預測和服務器校對的方式實時展現玩傢行動的方法,這樣做確保本地玩傢的行為立即生效,並消除瞭影響遊戲可玩性的延遲。但存在其它遊戲實體時仍有問題,這一章則討論瞭解決這類問題的兩個方法。
第一個是航位推算(dead reckoning),適用於某些類型的模擬:即遊戲中實體的位置可以通過如位置、速度和加速度這些已有數據估算出可用值。如果不符合這個條件這個方法就會失效。
第二個是實體插值(entity interpolation),不做預測而是使用服務器的真實數據,來展現出稍微在時間上延遲的其它實體。
最終的效果是玩傢的角色處於現在時但遊戲中的其它實體處於過去時,這通常能創造出極其流暢的體驗。
但一切還沒結束,在需要高精度的空間和時間的情況時以上方法就失效瞭,比如射擊一個運動中的目標:客戶端2展現出來的客戶端1的位置,與服務器中和客戶端1中的位置不一致,那就別想爆頭瞭!當然得有爆頭,下一章來解決這個問題!
爆頭
概要
前三章解釋的C/S方案可以總結如下:
客戶端上送給服務器的所有請求都帶上時間戳
服務器處理輸入指令並更新遊戲狀態
服務器定期下發遊戲狀態給所有客戶端
客戶端上送指令並在立即本地展現效果
客戶端取得遊戲狀態
同步預測服務端狀態
使用已有狀態為其它實體插值
從客戶端角度看,會產生兩個重要結果:
玩傢看到的自己是現在時
玩傢看到的其它實體是過去時
這個方法多數時有效,但在時間空間精度有要求的時候會很成問題,比如爆頭。
滯後補償(Lag Compensation)
當你端起狙擊槍完美的瞄準對手的腦袋時,你開槍瞭!必將一擊斃命!
但。。。沒中
怎麼回事兒?!
因為在上述C/S架構中,你瞄準的位置是100毫秒前對手的頭所在的位置,就像處於某個光速非常非常慢的宇宙中,你瞄準的是敵人過去的位置,當你扣下扳機時他已經跑瞭。
幸運的是,這個問題也有一個簡單的應對方案,對於大部分玩傢在大部分時間也是能被受的(有一個例外會討論)。
看看是怎麼做到的:
開槍時,客戶端把完整的信息上送給服務器:開槍時精確的時間戳和武器瞄準的精確位置;
關鍵是這一步:因為服務器有所有帶有時間戳的客戶端指令,服務器能準確的重建遊戲世界在過去任何一刻的狀態。事實上,服務器可以精確的重建任何客戶端在任何時間點的遊戲世界的狀態。
這意味著服務器知道在你出手的瞬間你的槍口對準的是什麼,那個位置那一刻是對手在過去時的腦袋,而且服務器也知道他腦袋的位置對於你是現在時。
服務器處理那個時間點的爆頭命中邏輯,並下發狀態給客戶端。
這個結果所有人都滿意!
服務器是老大,所以它總是滿意
你也滿意因為你瞄準對手的頭,開槍,並拿到瞭一個爆頭!
可能隻有對手不是完全滿意,如果當他被擊中時他正站在那兒沒動,那就是他的錯,對吧?但當時他在跑。。Wow,那你真的是神狙。
但如果他正好從開放地形躲到瞭墻後邊,還正想著安全時就在不到一秒內被擊中瞭呢?
是的,就這麼發生瞭,這是權衡的結果。因為你擊中的是過去時的他,就算那幾毫秒後他躲起來還是被命中瞭。
這是有點不公平,但這是所有參與者最滿意的解決方案,這比完美一槍去沒命中要好太多!
結論
快節奏多人遊戲系列到此結束。這類事情要做好的確很棘手,但如果清晰的理解瞭事情的原理,就不再那麼困難。
雖然這些文章是為遊戲開發者所寫,但另一組讀者也會感興趣,那就是玩傢!做為玩傢肯定也想知道為什麼出現這樣的情況,原因是什麼。
from:騰訊遊戲開發者平臺