漫談遊戲幀同步
在現代多人遊戲中,多個客戶端之間的通訊,多以同步多方狀態為主要目標。為了實現這個目標,主要有兩個方向的技術:
一種叫狀態同步:客戶端傳送遊戲動作到伺服器,伺服器收到後,計算遊戲行為的結果,然後通過廣播下發遊戲中各種狀態,客戶端收到狀態後顯示內容。這種做法類似於各個客戶端都遠端操作伺服器上的軟體。最早的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(玩家對戰)的幀同步遊戲中,不公平這個往往問題不大。我們完全可以在遊戲的不同玩法裡,開啟、調整、甚至關閉這個“鎖步”的機制,從而讓遊戲最大程度的平衡公平性和流暢性。