幀同步游戲開發基礎指南


最近一個月休了個假,體驗了一下類似歐洲的田園生活。所以更新幾乎荒廢了,但是總結和積累是一直持續着的。根據前一階段對於實時對戰游戲的開發思考,寫了這一篇入門級的文章,希望能記錄下自己的想法。

 

什么游戲適合幀同步這種技術?

 

在現代多人游戲中,多個客戶端之間的通訊,多以同步多方狀態為主要目標。為了實現這個目標,主要有兩個方向的技術:

一種叫狀態同步:客戶端發送游戲動作到服務器,服務器收到后,計算游戲行為的結果,然后通過廣播下發游戲中各種狀態,客戶端收到狀態后顯示內容。這種做法類似於各個客戶端都遠程操作服務器上的軟件。最早的mud,以及后來大量的國產網游,特別是回合制游戲,都是這種方式;

另外一種叫幀同步:客戶端發送游戲動作到服務器,服務器廣播轉發所有客戶端的動作(或者客戶端直接通過P2P技術發送),客戶端根據收到的所有游戲動作來做游戲運算和顯示。這種做法等於客戶端之間互相遠程控制其他客戶端上的游戲軟件。早期的IPX網絡游戲,如紅色警戒、帝國時代、星際爭霸,甚至大量的支持網絡連線雙打的游戲機模擬器,都是這種方式。

幀同步這種同步方式,主要依靠客戶端的能力,服務器僅僅是做一個轉發,甚至客戶端可以無需服務器,僅僅通過P2P方式來轉發數據。由於只轉發游戲行為,所以廣播的數據量比狀態同步要小很多,非常時候游戲行為非常頻繁的動作游戲,比如飛行射擊、FPS、RTS這類游戲。由於狀態同步要把整個游戲的狀態都廣播下去,如果游戲中的對象特別多,比如滿屏幕的子彈,很多怪物,那么要廣播的數據量就很大了,這個時候幀同步的優勢就比較明顯,因為不管有多少“機器控制的角色”,僅僅需要廣播玩家角色有關的操作即可。反過來說,如果游戲里是大量玩家聚集起來進行游戲的,那么幀同步和狀態同步的差異就不明顯了。反而狀態同步能得到更多安全性上的好處,因為游戲運算在服務器上,比較容易防止外掛。

 

幀同步技術的基礎概念

 

                                                                               

   相同的輸入 + 相同的時機 = 相同的顯示    

                                                                                

  意思是如果我們的游戲,接受了來自網絡的多個客戶端的操作,如果這些操作在各個客戶端是一樣的,那么多個客戶端的顯示也就一樣了,這就帶來了“同步”的效果。所以在這種情況下,各個客戶端的運算要絕對一致,不能依賴諸如本地時間、本地隨機數等等“輸入”,而要一切以網絡來的操作數據為主。

 

   在一般的幀同步系統中,會有一個Relay Server負責廣播(轉發)所有客戶端的數據。為了讓各個客戶端能持續的運行,而不是卡住,所以需要定時的下發一個個“網絡幀”數據來驅動各個客戶端。因為客戶端已經放棄了本地的時間,本地的循環驅動,所以這些“網絡幀”就必不可少了。這些網絡幀大部分實際上是“空”的,只有當玩家有輸入的時候,才會把玩家的游戲操作的數據,填入到網絡幀數據包中。對於客戶端來說,就好像有很多鍵盤、鼠標、游戲手柄在通過網絡操作自己一樣。

   一般來說,大多數的游戲客戶端引擎,都會定時調用一個接口函數,這個函數由用戶填寫內容,用來修改和控制游戲中各種需要顯示的內容。比如在Flash里面叫OnEnterFrame(),在Unity里面叫Update()。這類函數通常會在每幀畫面渲染前調用,當用戶修改了游戲中的各個角色的位置、大小后,就在下一幀畫面中顯示出來。而在幀同步的游戲中,這個Update()函數依然是存在,只不過里面大部分的內容,需要挪到另外一個類似的函數中,我們可以稱之為UpdateByNet()函數——由網絡層不斷的接收服務器發來的“網絡幀”數據包,每收到一個這樣的數據包,就調用一次這個UpdateByNet()函數,這樣游戲就從通過本地CPU的Update()函數的驅動,改為根據網絡來的UpdateByNet()函數驅動了。顯然,網絡發過來的同步幀速度會明顯比本地CPU要慢的多,這里就對我們的游戲邏輯開發提出了更高的要求——如何同步的同時,還能保證流暢?

幀同步的技術要點

 

幀同步游戲中,由於需要“每一幀”都要廣播數據,所以廣播的頻率非常高,這就要求每次廣播的數據要足夠的小。最好每一個網絡幀,能在一個MTU以下,這樣才能有效降低底層網絡的延遲。同樣的理由,我們為了提高實時性,一般也傾向於使用UDP而不是TCP協議,這樣底層的處理會更高效。但是,這樣也會帶來了丟包、亂序的可能性。因此我們常常會以冗余的方式——比如每個幀數據包,實際上是包含了過去2幀的數據,也就是每次發3幀的數據,來對抗丟包。也就是說三個包里面只要有一個包沒丟,就不影響游戲。另外我們還會在RelayServer上保存大量的客戶端上傳的數據,如果客戶端發現丟了包(如果亂序了也認為是丟包),那么就發起一次“下載”請求,從服務器上重新下載丟失了的幀數據包(這個可能會使用TCP)。這一切,都依賴於每個幀數據要足夠的小。所以我們一般要求,每次客戶端發送的數據,應該小於128字節。你可以大概計算一下,如果我們的游戲有4個玩家,我們的冗余是3幀,那么一個下行的網絡幀數據包大小會到128x4x3=1536字節,而每秒我們發15個網絡幀,那么占用的帶寬會到1536x15=23,040字節/秒,加上一些底層協議包頭也就是24kB/s,這個速度看起來已經要求手機是3G網絡才能支持了(實測中GPRS一般很難穩定到這個速度)。

我們使用的游戲引擎,特別是3D游戲引擎,里面使用的位置數據,大多數是浮點數,大家知道,一個浮點數需要占用8個字節,這可比簡單的整數4個字節大了足足一倍。而我們需要廣播的游戲操作,往往不需要那么高的精確度,所以我們應該把這些浮點數,想辦法變成整數來廣播。有時候我們甚至有可能只用1~2個字節(0-256-65535)來表達一個操作所需要的數字(比如按鍵值、鼠標坐標)。這樣就能大大降低廣播的數據長度。最簡單的方法,就是把浮點數乘以1000或100然后取整。

另外一個降低廣播數據量的做法就是自己編寫序列化函數:一般現代編程語言,特別是面向對象的語言,都帶有把對象序列化和反序列化的功能。我們要廣播游戲操作的時候,這些操作往往也是一個個的“對象”,因此最簡單的方法就是使用編程語言自帶的序列化庫來把對象轉換成字節數組去廣播。但是這些編程語言的默認序列化功能,為了實現諸如反射等高級功能,會把很多游戲邏輯所“不必要”的數據也序列化了,比如對象的類名、屬性名什么的。如果我們自己去針對特定的數據對象來編寫序列化函數,就沒有這個問題了,我們可以僅僅提取我們想要的數據,甚至能合並和裁剪一些數據項,達到最小化數據長度的目的。

 

   在網絡游戲中,各個客戶端的運行條件和環境往往千差萬別,有的硬件好一些,有的差一些,各方的網絡情況也不一致;時不時玩家的網絡還會在游戲過程中,發生臨時的擁堵,我們稱之為“網絡抖動”。網絡游戲有時候還會需要有中途加入游戲的需求(亂入),有游戲錄像和觀看、快進錄像的功能。這些功能,都可能導致客戶端收到“過去時間”里的一堆網絡幀,因此,客戶端必須要有處理這些堆積起來的網絡數據的能力。最簡單的做法就是加速播放(快進)——如果收到網絡數據處理完游戲邏輯后,然后在同一個渲染幀(同一次Update()函數里)內,馬上繼續收下一個網絡數據,然后又立刻處理。這樣往往能在一個渲染幀的時間內,加速趕上服務器廣播的最新游戲進度。但是這樣做也會有副作用,如果客戶端積累的包太多(比如游戲已經開始玩了10分鍾,新的用戶中途加入),會導致這個用戶長時間卡住,因為程序正在瘋狂的下載積累的幀同步包和運算快進。為了解決這個問題,有些程序員會限制每一個渲染幀中所快進的操作次數,這樣用戶還是能看到畫面有活動。如果實在要快進的進度太多,就要采用“快照”技術,通過定時保存的游戲狀態數據,來減少快進的進度了。這個快照功能這里就不展開了。

 

 

   一般來說,我們的客戶端的渲染幀率都會大大高於網絡幀的接收頻率。如果我們每個渲染幀都去發送一次玩家操作(比如觸摸屏上的手指位置),那么可能會導致發送的游戲操作遠遠大於收到的操作,這樣做要么會讓游戲操作堆積在服務器上,導致操作的嚴重延遲,要么導致下行的網絡包非常大(服務器每次都把收到的所有操作一次下發),這樣會讓網絡帶寬占滿,同樣是會感覺延遲。不管怎么處理,都是不太好的結果。正確的做法應該是控制發包頻率,最好是至少收到一個網絡下行幀,才發送一個上行的游戲操作,避免堆積。另外,剛剛講到的“快進”,如果我們在快速播放游戲邏輯的時候,每次播放同時也采集玩家輸入去發送,那么同樣會導致短時間內發送一大堆上行數據給服務器,而這些數據很可能客戶端接收時產生大量的延遲。所以最好是在快進的時候不采集玩家的輸入,因為玩家在看到快進過程中,實際上也很難有效的做出合理的反應,一個常見的做法,就是快進的時候,給游戲覆蓋一個“等待”或“Loading”的蒙皮層,讓玩家不可以輸入操作。

 

 

 

關於流暢度的優化

 

   實時同步游戲最重要的是流暢,然而影響游戲流暢的因素很多,網絡帶寬的限制,CPU運算和渲染效率的限制,都是很大的問題。所幸游戲本身還是有很多可以取舍的因素,這讓我們可以犧牲一些游戲不太重要的特性,去提高流暢度。

 

   第一個可以用來交換流暢度的是“一致性”特性。我們做幀同步的目標是各個客戶端都能看到一致的顯示。但是游戲內容有很多,有一部分內容是可以容忍“不一致”的,比如我們做飛行射擊彈幕游戲,滿屏幕有很多子彈,而每一顆子彈本身的存在的時間很短,如果我們不是做對打的游戲(而是一起打電腦),那么這些子彈是可以不一致的。又比如我們做一個橫版過關的配合游戲,幾個玩家一起打電腦控制的怪物,大家關心的是怪物是怎么被打死的,而玩法本身又比較容忍不一致(橫版動作游戲的攻擊范圍往往比較大),所以就算有些不一致問題也不大。在以上的條件下,我們就可以嘗試,把更多的游戲邏輯,從網絡幀的UpdateByNet()函數里面拿出去,放回到單機游戲中的Update()函數里去。這樣就算網絡有點卡,起碼整個畫面里還是有很多東西是不會被“卡住”的。但是必須注意的是,一般玩家控制的角色的動作,包括當前客戶端控制的角色,還是應該從網絡幀里面獲得行為數據,因為如果玩家愛控制角色不一致的太多,整個游戲場面就會差更多。很多游戲中的怪物AI都是根據玩家角色來設定的,所以一旦玩家角色的行為是同步的,那么大多數的怪物的表現還是一致的。

 

   第二個可以用來交換流暢度的特性是實時性。一般來說,我們都希望游戲中的角色控制是靈敏的,實時的。我們的游戲角色往往在會玩家輸入操作后的幾十分之一秒內,就開始顯示變化。在幀同步游戲中,我們可以讓玩家一輸入完操作,就立刻發包,然后盡快在下一個收到的網絡幀中收到這個操作,從而盡快的完成顯示。然而,網絡並不是那么穩定,我們常常會發現一會快一會慢,這樣玩家的操作體驗就非常奇怪,無法預測輸入動作后,角色會在什么時候起反應。這對於一些講求操作實時性的游戲是很麻煩的。比如球類游戲,控制的角色跑的一會兒快一會兒慢,很難玩好“微操”。要解決這個問題,我們一般可以學習傳輸語音業務的做法,就是接收網絡數據時,不立刻處理,而是給所有的操作增加一個固定的延遲,后在延遲的時間內,搜集多幾個網絡包,然后按固定的時間去播放(運算)。這樣相當於做了一個網絡幀的緩沖區,用來平滑那些一會兒快一會兒慢的數據包,改成勻速的運算。這種做法會讓玩家感覺到一個固定延遲:輸入操作后,最少要隔一段時間,才會起反應。但是起碼這個延遲是固定的,可預計的,這對於游戲操作就便捷很多了,只要掌握了提前量,這個操作的感覺就好像角色有一定的“慣性”一樣:按下跑並不立刻跑,松開跑不會立刻停,但這個慣性的時間是固定的。

 

   第三個用來交換流暢性的特性是公平性,這個特性其實和一致性有所類似。我們和其他玩家一起游戲的時候,有時候不希望對方因為電腦速度比較快,網絡比較好,而能比我們更早的看到游戲的運行結果,從而提早作出操作。這一點在格斗對打游戲(如《街霸》)里面非常關鍵,在一些RTS(《星際爭霸》)里面,提早看到游戲運行結果也是很有競爭優勢的。因此我們為了讓網絡、硬件不一樣的玩家能公平游戲,往往會使用一種叫“鎖步”的策略:就好像一串綁着腳鐐的囚犯,他們只能一起抬起左腳,然后再一起抬起右腳的走路,誰也不能走的更快。技術上的實現,就是每個客戶端都定時(每N個渲染幀)發送一個網絡幀到服務器上,就算玩家沒操作,也類似心跳的這樣發送空數據幀,所有客戶端都要完整的收到所有的其他客戶端的“心跳幀”才能開始運算一次游戲邏輯。這就是讓所有的客戶端,都互相等待,如果任何一個客戶端卡了,其他的客戶端都立刻就能知道,然后彈出界面讓玩家停止輸入來等待。因此在很多場合,幀同步的技術也被成為“鎖步”技術,事實上,在沒有統一的Relay Server服務器的時代(IPX局域網連機對戰的時代),幀同步的網絡幀其實就是上面所說的某個客戶端的“心跳幀”,是由某個客戶端產生並廣播的(比如以前的局域網游戲,都會由一個客戶端充當Host主機)。在《星際爭霸》連機游戲中,如果有一個玩家掉線了,所有其他玩家就會發現有一個界面彈出來擋住畫面,表示在等某某某。這種做法實際上是犧牲了流暢度的,因為你會發現一旦有網絡、硬件卡的玩家加入游戲,所有其他玩家都受他的影響。為了減少這種對流暢度的影響,我們可以在需要“鎖步”的時候,盡量少鎖一點,比如不是發現缺了一幀就停下來,而是缺了若干幀,還是可以以“不公平”的方式繼續玩一會兒(比如幾秒),如果這段時間內還是沒有補齊所缺的幀,才宣布鎖住游戲等待。當然這個“容忍”的幀數我們可以調節到“最大”——就是沒有。那么一個完全不鎖步的游戲,肯定不是一個公平的游戲,但是也會在流暢性產生最大的好處,就是完全不受其他玩家影響。在那些不是PVP(玩家對戰)的幀同步游戲中,不公平這個往往問題不大。我們完全可以在游戲的不同玩法里,打開、調整、甚至關閉這個“鎖步”的機制,從而讓游戲最大程度的平衡公平性和流暢性。

 

總結

 

   幀同步游戲技術,並不存在一種可以讓游戲流暢的通用做法,而是需要和游戲具體做很多結合,在減少數據包,優化游戲快進體驗,控制發包速度上盡量調優。同時還需要和游戲產品策划一起,平衡一致性、實時性、公平性的策略,才能真正達到流暢游戲的目的。

 

往期精彩內容回看:

回復服務器架構——經典游戲服務器架構概述

回復程序員——如何提高程序員的生產率

回復RPC——如何設計一個RPC系統

回復架構模式——經典軟件架構模式

回復游戲服務器——可復用的游戲服務器端開發框架

回復技術總監——如何做一個小型公司的技術總監

 

 

感謝大家的閱讀,如覺得此文對你有那么一丁點的作用,麻煩動動手指轉發或分享至朋友圈。如有不同意見,歡迎后台留言探討。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM