回放,是電子游戲中一項常見的功能,用于記錄整個比賽過程或者展示游戲中的精彩瞬間。通過回放,我們可以觀摩高手之間的對決,享受游戲中的精彩瞬間,甚至還可以拿到敵方玩家的比賽錄像進行分析和學習。
從實現技術角度來講,下面的這些功能本質上都屬于回放的一部分
- 精彩瞬間展示:FIFA / 實況足球 / NBA2K / 守望先鋒 / 極限競速:地平線 / 跑跑卡丁車
- 死亡回放:守望先鋒 / 彩虹六號 / 使命召喚 / CODM
- 全局比賽錄制、下載、播放:守望先鋒 / CSGO / Dota / LOL / 魔獸爭霸 / 星際爭霸 / 紅色警戒 / 坦克世界 / 絕地求生 / 王者榮耀
- 觀戰(zhàn)(常用于非實時觀戰(zhàn)):CSGo / 堡壘之夜 / Dota
- 時光倒流:Braid / 極限競速:地平線

早在20世紀90年代,回放系統就已經誕生并廣泛用于即時戰(zhàn)略、第一人稱射擊以及體育競技等類型的游戲當中,而那時存儲器的容量非常有限,遠遠無法與當今動輒幾十T的硬盤相提并論,面對一場數十分鐘的比賽,比賽數據該如何存儲和播放?回放該如何實現?這篇文章會通過剖析UE的回放系統,來由淺入深地幫助大家理解其中的原理和細節(jié)。
概述
其實實現回放系統有三種思路,分別是:
- 逐幀錄制游戲畫面
????- 播放簡單,方便分享
????- 性能開銷大,占用空間,不靈活
- 逐幀錄制玩家的輸入操作
????- 錄制數據小,靈活
????- 跳躍、倒退困難,計算一致性處理復雜
- 定時錄制玩家以及游戲場景對象的狀態(tài)
????- 錄制數據較少,開銷可控,靈活
????- 邏輯復雜
三種方案各有優(yōu)劣,但由于第一種錄制畫面的方案存在著“占用大量存儲空間”、”加載速度慢”、“不夠靈活”等比較嚴重的問題,我們通常采用后兩種方式來實現游戲中的回放。
可以參考“游戲中的回放系統是如何實現的?”來進一步了解這三種方案
一、幀同步、快照同步與狀態(tài)同步
雖然不同游戲里回放系統具體的實現方式與應用場景不同,但本質上都是對數據的記錄和重現,這個過程與網絡游戲里面的同步技術非常相似。舉個例子,假如AB兩個客戶端進行P2P的連接對戰(zhàn),A客戶端上開始時并沒有關于B的任何信息。當建立連接后,B開始把自己的相關信息(坐標,模型,大?。┌l(fā)給A,A在自己的客戶端上利用這個信息重新構建了B,完成了數據的同步。
思考一下,假如B不把這個信息發(fā)給A,而發(fā)給自己進行處理,是不是就相當于錄制了自己的機器上的比賽信息再進行回放呢?

沒錯,網絡游戲中的同步信息正是回放系統中的錄制信息,因此網絡同步就是實現回放系統的技術基礎!
在正式介紹回放系統前,不妨先概括地介紹一下游戲開發(fā)中的網絡同步技術。我們常說網絡同步可以簡單分為幀同步、快照同步和狀態(tài)同步,但實際上這幾個中文概念是國內開發(fā)者不斷摸索和自創(chuàng)的名詞,并非嚴格指某種固定的算法,他們有很多變種,甚至可以結合到一起去使用。
- 幀同步,對應的英文概念是LockStep/Deterministic Lockstep。其基本思路是每固定間隔(如0.02秒)對玩家的行為進行一次采樣得到一個“Input指令” 并發(fā)送給其他所有玩家,每個玩家都緩存來自其他所有玩家的“Input指令” ,當某個玩家收到所有其他玩家的“Input指令”后,他的本地游戲狀態(tài)才會推進到下一幀。

- 快照同步,可以翻譯成Snapshot Synchronization。其思想是服務器把當前這幀整個游戲世界的狀態(tài)進行一個備份,然后把這個備份發(fā)送給所有客戶端,客戶端按照這個備份對自己的世界狀態(tài)進行修改和糾正進而完成同步。(快照,對應的英文概念是SnapShot,強調的是某一時刻的數據狀態(tài)或者備份。從游戲世界的角度理解,快照就是整個世界所有的狀態(tài)信息,包括對象的數量、對象的屬性、位置線信息等。從每個對象的角度理解,快照就是指整個對象的各種屬性,比如生命值、速度這些。所以,不同場景下快照所指的內容可能是不同的。)

- 狀態(tài)同步,可以翻譯成State(State Based)Synchronization。其思想與快照同步相似,也是服務器將世界的狀態(tài)同步給客戶端。但不同的是狀態(tài)同步的粒度變得非常?。ㄒ詫ο蠡蛘邔ο蟮膶傩詾閱挝唬?,服務器不需要把一幀里面所有的對象狀態(tài)進行保存和同步,只需要把客戶端需要的那些對象以及需要的屬性進行保存和發(fā)送即可。

拓展:快照同步其實是狀態(tài)同步的前身,那時候整個游戲需要記錄的數據量還不是很大,人們也自然的使用快照來代表整個世界在某一時刻的狀態(tài),通過定時地同步整個世界的快照就可以做到完美的網絡同步。但是這種直接把整個世界的狀態(tài)進行同步的過程是很耗費流量和性能的,考慮到對象的數據是逐步產生變化的,我們可以只記錄發(fā)生變化的那些數據,所以就有了基于delta的快照同步。更進一步的,我們可以把整個世界拆分一下,每一幀只針對需要的對象進行delta的同步,這樣就完全將各個對象的同步拆分開來,再結合一些過濾可以進一步減少沒必要的數據同步,最后形成了狀態(tài)同步的方案。更多關于網絡同步技術的發(fā)展和細節(jié)可以參考我的文章——《細談網絡同步在游戲歷史中的發(fā)展變化》。
二、UE4網絡同步基礎
在虛幻引擎里面,默認實現的是一套相對完善的狀態(tài)同步方案,場景里面的每個對象都稱為一個Actor,每個Actor都可以單獨設置是否進行同步(Actor身上還可以掛N個組件,也可以進行同步),Actor某一時刻的標記Replicated屬性就是所謂的狀態(tài)信息。服務器在每幀Tick的時候,會去判斷哪些Actor應該同步給哪些客戶端,哪些屬性需要進行同步,然后統一序列化成二進制(可以理解為一個當前世界狀態(tài)的增量快照)發(fā)給對應的客戶端,客戶端在收到后還可以調用回調函數進一步處理。這種通信方式我們稱為屬性同步。
此外,UE里面還有另一種通信方式叫RPC,可以像調用本地函數那樣來調用遠端的函數。RPC常用于做一些跨端的事件通知,雖然并不嚴格屬于傳統意義上狀態(tài)同步的范疇,但也是UE網絡同步里面不可缺少的一環(huán)。

為了實現上面兩種同步方式,UE4通過抽象分層實現了一套NetDriver + NetConnection + Channel + Actor/Uobject的同步方式(如下圖)。
- NetDriver:網絡驅動管理,封裝了同步Actor的基本操作,還包括初始化客戶端與服務器的連接,建立屬性同步記錄表,處理RPC函數,創(chuàng)建Socket,構建并管理Connection信息,接收數據包等等基本操作。
- Connection:表示一個網絡連接。服務器上,一個客戶端到一個服務器的一個連接叫一個ClientConnection。在客戶端上,一個服務器到一個客戶端的連接叫一個ServerConnection。
- Channel:數據通道,每一個通道只負責交換某一個特定類型特定實例的數據信息。比如一個ActorChannel只負責處理對應Actor本身相關信息的同步,包括自身的同步以及子組件、屬性的同步、RPC調用等。

三、回放系統框架與原理
3.1 回放系統的核心與實現思路
結合我們前面提到的網絡同步技術,假如我們現在想在游戲里面錄制一場比賽要怎么做呢?是不是像快照同步一樣把每幀的狀態(tài)數據記錄下來,然后播放的時候再去讀取這些數據呢?沒錯!利用網絡同步的思想,把游戲本身當成一個服務器,游戲內容當成同步數據進行錄制存儲即可。
當然對于幀同步來說,我們并不會去記錄不同時刻世界的狀態(tài)信息,而是把關注點放在了玩家的行為指令上(Input隊列)。幀同步會默認各個客戶端的初始狀態(tài)完全一致,只要保證同一時刻每個指令的相同,那么客戶端上整個游戲世界的推進和表現也應該是完全一樣的(需要解決浮點數精度、隨機數一致性問題等)。由于只需要記錄玩家的行為數據,所以一旦幀同步的框架完成,其回放系統的實現是非常方便和輕量化的。
無論哪種方式,回放系統都需要依靠網絡同步框架來實現。虛幻系統本身是狀態(tài)同步架構,所以我們后面會把重點都放在基于狀態(tài)同步的回放系統中去。
如果你想深入UE4的網絡同步,好好研究回放系統是一個不錯的學習途徑。官方文檔鏈接:
https://docs.unrealengine.com/4.27/en-US/TestingAndOptimization/ReplaySystem/
根據上面的闡述,我們已經得到了實現回放系統的基本思路:
1. 錄制:就像服務器網絡同步一樣,每幀去記錄所有對象(Actor)的狀態(tài)信息,然后通過序列化的方式寫到一個緩存里面。
2. 播放:拿到那個緩存數據,反序列化后賦值給場景里面對應的Actor。
序列化:把對象存儲成二進制的形式。
反序列化:根據二進制數據的內容,反過來還原當時的對象。
3.2 UE4回放系統的簡單使用
為了能有一個直觀的效果,我們先嘗試動手錄制并播放一段回放,步驟如下:
1. 在EpicLancher里面下載引擎(我使用的是4.26版本),創(chuàng)建一個第三人稱的模板工程命名為MyTestRec;
2. 點擊Play進入游戲后,點擊“~”按鈕并在控制臺命令執(zhí)行demorec MyTestReplay開始錄制回放;
3. 隨便移動人物,30秒后再次打開控制臺命令執(zhí)行Demostop;
4. 再次打開控制臺,命令執(zhí)行demoplay MyTestReplay,可以看到地圖會被重新加載然后播放剛才錄制的30秒回放。

3.3 UE4中的回放系統架構
虛幻引擎在NetDriver + NetConnection + Channel的架構基礎上(上一節(jié)有簡單描述) ,拓展了一系列相關的類來實現回放系統(ReplaySystem):
- UReplaySubsystem:一個全局的回放子系統,用于封裝核心接口并暴露給上層調用。(注:Subsystem類似設計模式中的單例類。)
- DemoNetdriver:繼承自NetDriver,專門用于宏觀地控制回放系統的錄制與播放。
- Demonetconnection:繼承自NetConnection,可以自定義實現回放數據的發(fā)送位置。
- FReplayHelper:封裝一些回放處理數據的接口,用于將回放邏輯與DemoNetDriver進行解耦。
- XXXNetworkReplayStreamer:回放序列化數據的存儲類,根據不同的存儲方式有不同的具體實現。

3.3.1 數據的存儲和讀取概述
在前面的示例中,我們通過命令demorec將回放數據錄制到本地文件,然后再通過命令demoplay找到對應名稱的錄制并播放,這些命令會被UWorld::HandleDemoPlayCommand解析,進而調用到回放系統的真正入口StartRecordingReplay/ StopRecordingReplay/ PlayReplay。

入口函數被封裝在UGameinstance上并且會最終執(zhí)行到回放子系統UReplaySubsystem上(注:一個游戲客戶端/服務器對應一個GameInstance)。

數據的存儲:
當我們通過RecordReplay開始錄制回放時,UReplaySubsystem會創(chuàng)建一個新的DemoNetDriver并初始化DemonetConnection、ReplayHelper、ReplayStreamer等相關的對象。接下來便會在每幀結尾時通過TickDemoRecord對所有同步對象進行序列化(序列化的邏輯完全復用網絡同步框架)。
由于UDemoNetConnection重寫了LowLevelSend接口,序列化之后這些數據并不會通過網絡發(fā)出去,而是先臨時存儲在ReplayHelper的FQueuedDemoPacket數組里面。


不過QueuedDemoPackets本身不包含時間戳等信息,還需要再通過FReplayHelper::WriteDemoFrame將當前Connection里面的QueuedDemoPacket與時間戳等信息一同封裝并寫到對應的NetworkReplayStreamer里面,然后再交給Streamer自行處理數據的保存方式,做到了與回放邏輯解耦的目的。

數據的讀取:
與數據的存儲流程相反,當我們通過PlayReplay開始播放回放時,需要先從對應的NetworkReplayStreamer里面取出回放數據,然后解析成FQueuedDemoPacket數組。隨后每幀在TickDemoPlayback根據Packet里面的時間戳持續(xù)不斷地進行反序列化來恢復場景里面的對象。

到這里,我們已經整理出了錄制和回放的大致流程和入口位置。但為了能循序漸進地剖析回放系統,我還故意隱藏了很多細節(jié),比如說NetworkReplayStreamer里面是如何存儲回放數據的?回放系統如何做到從指定時間開始播放的?想弄清這些問題就不得不進一步分析回放相關的數據結構與組織思想。

3.3.2 回放數據結構的組織和存儲
無論通過哪種方式實現回放都一定會涉及到快進、暫停、跳轉等類似的功能。然而,我們目前使用的方式并不能很好地支持跳轉,主要問題在于虛幻引擎默認使用增量式的狀態(tài)同步,任何一刻的狀態(tài)數據都是前面所有狀態(tài)同步數據的疊加,必須從最開始播放才能保證不丟失掉中間的任何一個數據包。比如下圖的例子,如果我想從第20秒開始播放并且從第5個數據包開始加載,那么一定會丟失Actor1的創(chuàng)建與移動信息。

數據流在錄制的時候中間是沒有明確分割的,也就是所有的序列化數據都緊密地連接在一起的,無法進行拆分,只能從頭開始一點點讀取并反序列化解析。中間哪怕丟了一個字節(jié)的數據都可能造成后面的數據解析亂掉。
為了解決這個問題,Unreal對數據流進行了分類:
- Checkpoint:存檔點,即一個完整的世界快照(類似單機游戲中的存檔),通過這個快照可以完全回復當時的游戲狀態(tài)。每隔一段時間(比如30s)存儲一個Checkpoint。
- Stream:一段連續(xù)時間的數據流,存儲著從上一個Checkpoint到當前的所有序列化錄制數據。
- Event:記錄一些特殊的自定義事件。

通過這種方式,我們在任何時刻都可以找到一個臨近的全局快照(Checkpoint)并進行加載,然后再根據最終目標的時間快速地讀取后續(xù)的Stream信息來實現目標位置的跳轉。拿前面的案例來說,由于我現在在20s的時候可以通過Checkpoint的加載而得到前面Actor1在當前的狀態(tài),所以可以完美地實現跳轉功能。在實際錄制的時候,ReplayHelper的FQueuedDemoPacket其實有兩個,分別用于存儲Stream和Checkpoint。

只有達到存儲快照的條件時間時(可通過控制臺命令設置CVarCheckpointUploadDelay InSeconds設置),我們才會調用SaveCheckpoint函數把表示Checkpoint的QueuedCheckpointPackets寫到NetworkReplayStreamer,其他情況下我們則會每幀把QueuedDemoPackets表示的Stream數據進行寫入處理。

每次回放開始前我們都可以傳入一個參數用來指定跳轉的時間點,隨后就會開啟一個FPendingTaskHelper的任務,根據目標時間找到前面最靠近的快照,并通過UDemoNetDriver:: LoadCheckpoint函數來反序列化恢復場景對象數據(這一步完成Checkpoint的加載)。
如果目標時間比快照的時間要大,則需要在ConditionallyReadDemoFrameInto PlaybackPackets快速地把這段時間差的數據包全部讀出來并進行處理,默認情況下在一幀內完成,所以玩家并無感知(數據流太大的話會造成卡頓,可以考慮分幀)。

前面提到的QueuedDemoPackets只是臨時緩存在ReplayHelper里,那最終序列化的Stream和Checkpoint具體存儲在哪里呢?答案就是我們多次提到的NetworkReplayStreamer。在NetworkReplayStreamer里面會一直維護著StreamingAr和CheckpointAr兩個數據流,DemonetDriver里面對回放數據的存儲和讀取本質上都是對這兩個數據流的修改。
Archive可以翻譯成檔案,在虛幻里面是用來存儲序列化數據的類。其中FArchive是數據存儲的基類,封裝了一些序列化/反序列化等操作的接口。我們可以通過繼承FArchive來實現自定義的序列化操作。
那這兩個Archive具體是如何存儲和維護的呢?為了能有一個直觀的展示,建議大家先去按照2.3小結的方式去操作一下,然后就可以在你工程下/Saved/Demo/路徑下得到一個回放的文件。這個文件主要存儲的就是多個Stream和一個Checkpoint,打開后大概如下圖(因為是序列化成了2進制,所以是不可讀的)

接下來我們先打開LocalFileNetworkReplayStreaming.h文件,并找到StreamAr和CheckpointAr這兩個成員,查看FLocalFileStreamFArchive的定義。

FLocalFileStreamFArchive繼承自FArchive類,并重寫了Serialize(序列化)函數,同時聲明了一個TArray<uint8>的數組來保存所有序列化的數據,那些QueuedDemoPacket里面的二進制數據最終都會寫到這個Buffer成員里面。不過StreamAr和CheckpointAr并不會一直保存著所有的錄制數據,而是定時把數據通過Flush寫到本地磁盤里面,寫完后Archive里面的數據就會清空,接著存儲下一段時間的回放信息。
而在讀取播放時,數據的處理流程會有一些差異。系統會嘗試一次性從磁盤加載所有信息到一個用于組織回放的數據結構中——FLocalFileReplayInfo,然后再逐步讀取與反序列化,因此下圖的FLocalFileReplayInfo在回放開始后其實已經完整地保存著一場錄制里面的所有的序列化信息了(Chunks數組里面就存儲著不同時間段的StreamAr)。


FLocalFileNetworkReplayStreamer是為了專門將序列化數據寫到本地而封裝的類,類似的還有用于Http發(fā)送的FHttpNetworkReplayStreamer。這些類都繼承自接口INetworkReplayStreamer,在第一次執(zhí)行錄制的時候會通過對應的工廠類進行創(chuàng)建。

- Http:把回放的數據定時通過Http發(fā)送到一個指定URL的服務器上
- InMemory:不斷將回放數據寫到內存里面,可以隨時快速地取出
- LocalFile:寫到本地指定目錄的文件里面,維護了一個FQueuedLocalFileRequest隊列不停地按順序處理數據的寫入和加載
- NetWork:各種基類接口、基類工廠
- Null:早期默認的存儲方式,通過Json寫到本地文件里面,但是效率比較低(已廢棄)
- SavGame:LocalFile的前身,現在已經完全繼承并使用LocalFile的實現

我們可以通過在StartRecordingReplay/PlayReplay的第三個參數(AdditionalOptions)里面添加“ReplayStreamerOverride=XXX”來設置不同類型的ReplayStreamer,同時在工程的Build.cs里面配置對應的代碼來確保模塊能正確的加載。

當然,在NetworkReplayStreamer還有許多重要的函數,比如我們每次錄制或者播放回放的入口Startstream會事先設置好我們要存儲的位置、進行Archive的初始化等,不同的Streamer在這些函數的實現上差異很大。


3.3.3 回放架構梳理小結
到此,我們已經對整個系統有了更深入的理解,再回頭看整個回放的流程就會清晰很多。
1. 游戲運行的任何時候我們都可以通過StartRecordingReplay執(zhí)行錄制邏輯,然后通過初始化函數創(chuàng)建DemonetDriver、DemonetConnection以及對應的ReplayStreamer。
2. DemonetDriver在Tick的時候會根據一定規(guī)則對當前場景里面的同步對象進行錄制,錄制的數據先存儲到FQueuedDemoPacket數組里面,然后再寫到自定義ReplayStreamer的FArcive里面緩存。
3. FArcive分為StreamAr和CheckpointAr,分別用持續(xù)的錄制和特定時刻的全局快照保存,里面的數據到達一定量時我們就可以把他們寫到本地或者發(fā)送出去,然后清空后繼續(xù)錄制。
4. 當執(zhí)行PlayReplay開始回放的時候,我們先根據時間戳找到就近的CheckpointAr進行反序列化,利用快照恢復整個場景后再使用Tick去讀取StreamAr里面的數據并播放。
回放系統的Connection是100%Reliable的,Connection->IsInternalAck()為true。

3.4 回放實現的錄制與加載細節(jié)
上個小結我們已經從架構的角度上梳理了回放錄制的原理和過程,但是還有很多細節(jié)問題還沒有深究,比如:
- 回放時觀看的視角如何設置?
- 哪些對象應該被錄制?
- 錄制頻率如何設置?
- RPC和屬性都能正常錄制么?
- 加載Checkpoint的時候要不要刪除之前的Actor?
- 快進和暫停如何實現?
這些問題看似簡單,但實現起來卻并不容易。比如我們在播放時需要動態(tài)切換特定的攝像機視角,那就需要知道UE里面的攝像機系統,包括Camera的管理、如何設置ViewTarget、如何通過網絡GUID找到對應的目標等,這些內容都與游戲玩法高度耦合,因此在分析錄制加載細節(jié)前建議先回顧一下UE的Gameplay框架。
3.4.1 回放世界的Gameplay架構
UE的Gameplay基本是按照面向對象的方式來設計的,涉及到常見概念(類)如下:
- World:對應一個游戲世界
- Level:對應一個子關卡,一個World可以有很多Level
- Controller/PlayerController:玩家控制器,可以接受玩家輸入,設置觀察對象等
- Pawn/Character:一個可控的游戲單位,Character相比Pawn多了很多人型角色的功能,比如移動、下蹲、跳躍等
- CameraManager:所有攝像機相關的功能都通過CameraManager管理,比如攝像機的位置、攝像機震動效果等
- GameMode:用于控制一場比賽的規(guī)則
- PlayerState:用于記錄每個玩家的數據信息,比如玩家的得分情況
- GameState:用于記錄整場比賽的信息,比如比賽所處的階段,各個隊伍的人員信息等

概括來講,一個游戲場景是一個World,每個場景可以拆分成很多子關卡(即Level),我們可以通過配置Gamemode參數來設置游戲規(guī)則(只存在與于服務器),在Gamestate上記錄當前游戲的比賽狀態(tài)和進度。對于每個玩家,我們一般至少會給他一個可以控制的角色(即Pawn/Character),同時把這個角色相關的數據存儲在Playerstate上。最后,針對每個玩家使用唯一的一個控制器Playercontroller來響應玩家的輸入或者執(zhí)行一些本地玩家相關的邏輯(比如設置我們的觀察對象VIewTarget,會調用到Camermanager相關接口)。此外,PC是網絡同步的關鍵,我們需要通過PC找到網絡同步的中心點進而剔除不需要同步的對象,服務器也需要依靠PC才能判斷不同的RPC應該發(fā)給哪個客戶端。
回放系統Gameplay邏輯依然遵循UE的基礎框架,但由于只涉及到數據的播放還是有不少需要注意的地方。
- 在一個Level里,有一些對象是默認存在的,稱為StartupActor。這些對象的錄制與回放可能需要特殊處理,比如回放一開始就默認創(chuàng)建,盡量避免動態(tài)的構造開銷。
- UE的網絡同步本身需要借助Controller定位到ViewTarget(同步中心,便于做范圍剔除),所以回放錄制時會創(chuàng)建一個新的DemoPlayerController(注意:因為在本地可能同時存在多個PC,獲取PC時不要拿錯了)。這個Controller的主要用途就是輔助同步邏輯,而且會被錄制到回放數據里面。

- 回放系統并不限制你的觀察視角,但是會默認提供一個自由移動的觀戰(zhàn)對象(SpectatorPawn)。當我們播放時會收到同步數據并創(chuàng)建DemoPC,DemoPC會從GameState上查找SpectatorClass配置并生成一個用于觀戰(zhàn)的Pawn。我們通常會Possess這個對象并移動來控制攝像機的視角,當然也可以把觀戰(zhàn)視角鎖定在游戲中的其他對象上。
- 回放不建議錄制PlayerController(簡稱PC),游戲中的與玩家角色相關的數據也不應該放在PC上,最好放在PlayerState或者Character上面。為什么回放不處理PC?主要原因是每個客戶端只有一個PC。如果我在客戶端上面錄制回放并且把很多重要數據放在PC上,那么當你回放的時候其他玩家PC上的數據你就無法拿到。
- 回放不會錄制Gamemode,因為Gamemode只在服務器才有,并不做同步。

3.4.2 錄制細節(jié)分析
錄制Stream
TickDemoRecordFrame每一幀都會去嘗試執(zhí)行,是錄制回放數據的關鍵。其核心思想就是拿到場景里面所有需要同步的Actor,進行一系列過濾后把需要同步的數據序列化。步驟如下:
1. 通過GetNetworkObjectList獲取所有Replicated的Actor。
2. 找到當前Connection的DemoPC,決定錄制中心坐標(用于剔除距離過遠對象)。
3. 遍歷所有同步對象,通過NextUpdateTime判斷是否滿足錄制時間要求。
4. 通過IsDormInitialStartupActor排除休眠對象。
5. 判斷相關性,包括距離判定、是不是bAlwaysRelevant等。
6. 加入PrioritizedActors進行同步前的排序。
7. ReplicatePrioritizedActors對每個Actor進行序列化。
8. 根據錄制頻率CVarDemoRecordHz/CVarDemoMinRecordHz,更新下次同步時間NextUpdateTime。
9. DemoReplicate Actor處理序列化,包括創(chuàng)建通道Channel、屬性同步等。
10. LowLevelSend寫入QueuedPacket。
11. WriteDemoFrameFrom QueuedDemoPackets將QueuedPackets數據寫入到StreamArchive。
在同步每個對象時,我們可以通過CVarDemoRecordHz和CVarDemoMinRecordHz兩個參數來控制回放的錄制頻率,此外我們也可以通過Actor自身的NetUpdateFrequency來設置不同Actor的錄制間隔。
上述的邏輯主要針對Actor的創(chuàng)建銷毀以及屬性同步,那么我們常見的RPC通信在何時錄制呢?答案是在Actor執(zhí)行RPC時。每次Actor調用RPC時,都會通過CallRemoteFunction來遍歷所有的NetDriver觸發(fā)調用,如果發(fā)現了用于回放的DemoNetdriver就會將相關的數據寫到Demonet connection的QueuedPackets。


然而在實際情況下,UDemoNetDriver重寫了ShouldReplicateFunction/ProcessRemoteFunction,默認情況下只支持錄制多播類型的RPC。


為什么要這么做呢?
- RPC的目的是跨端遠程調用,對于非多播的RPC,他只會在某一個客戶端或者服務器上面執(zhí)行。也就是說,我在服務器上錄制就拿不到客戶端的RPC,我在客戶端上錄制就拿不到服務器上的RPC,總會丟失掉一些RPC。
- RPC是冗余的,可能我們在回放的時候不想調用。比如服務器觸發(fā)了一個ClientRPC(讓客戶端播放攝像機震動)并錄制,那么回放的時候我作為一個觀戰(zhàn)的視角不應該調用這個RPC(當然也可以自定義的過濾掉)。
- RPC是一個無狀態(tài)的通知,一旦錯過了就再也無法獲取?;胤胖薪洺袝r間的跳轉,跳轉之后我們再就無法拿到前面的RPC了。如果我們過度依賴RPC做邏輯處理,就很容易出現回放表現不對的情況。
綜上所述,我并不建議在支持回放系統的游戲里面頻繁使用RPC,最好使用屬性同步來代替,這樣也能很好的支持斷線重連。
- 錄制Checkpoint
在每幀執(zhí)行TickDemoRecord時,會根據ShouldSaveCheckpoint來決定是否觸發(fā)Checkpoint快照的錄制,可以通過CVarCheckpointUpload DelayInSeconds命令行參數來設置其錄制間隔,默認30秒。

存儲Checkpoint的步驟如下:
1. 通過GetNetworkObjectList獲取所有Replicated的Actor
2. 過濾掉PendingKill,非DemoPC等對象并排序
3. 構建快照上下文CheckpointSaveContext,把Actor以及對應的LevelIndex放到PendingCheckpointActors數組里面
4. 調用FReplayHelper:: TickCheckpoint,開始分幀處理快照的錄制(避免快照錄制造成卡頓)。實現方式是構建一個狀態(tài)機,會根據當前所處的狀態(tài)決定進入哪種邏輯,如果超時就會保存當前狀態(tài)在下一幀執(zhí)行的時候繼續(xù)
1)第一步是ProcessCheckpoint Actors,遍歷并序列化所有Actor的相關數據
2)進入SerializeDeleted StartupActors狀態(tài),處理那些被刪掉的對象
3)緩存并序列化所有同步Actor的GUID
4)導出所有同步屬性基本信息FieldExport GroupMap,用于播放時準確且能兼容地接收這些屬性
5)通過WriteDemoFrame把所有QueuedPackets寫到Checkpoint Archive里面
6)調用FlushCheckpoint把當前的StreamArchive和Checkpoint Archive寫到目標位置(內存、本地磁盤、Http請求等)


3.4.3 播放細節(jié)分析
- 播放Stream
當我們觸發(fā)了PlayReplay開始回放后,每一幀都會在開始的時候嘗試執(zhí)行TickDemoPlayback來嘗試讀取并解析回放數據。與錄制的邏輯相反,我們需要找到Stream數據流的起始點,然后進行反序列化的操作。步驟如下:
1. 確保當前World沒有進行關卡的切換,確保當前的比賽正在播放
2. 嘗試設置比賽的總時間SetDemoTotalTime
3. 調用ProcessReplayTasks處理當前正在執(zhí)行的任務,如果任務沒有完成就返回(任務有很多種,比如FGotoTime InSecondsTask就是用來執(zhí)行時間跳轉的任務)
4. 拿到StreamArchive,設置當前回放的時間(回放時間決定了當前回放數據加載的進度)
5. 去PlaybackPackets查找是否還有待處理的數據,如果沒有數據就暫?;胤?/p>
6. ConditionallyReadDemo FrameIntoPlaybackPackets根據當前的時間,讀取StreamArchive里面的數據,緩存到PlaybackPackets數組里面
7. ConditionallyProcess PlaybackPackets逐個去處理PlaybackPackets里面的數據,進行反序列化的操作(這一步是還原數據的關鍵,回放Actor的創(chuàng)建通常是這里觸發(fā)的)
8. FinalizeFastForward處理快進等操作,由于我們可能在一幀的時候處理了回放N秒的數據(也就是快進),所以這里需要把被快進掉的回調函數(OnRep)都執(zhí)行到,同時記錄到底快進了多少時間
- 加載Checkpoint
在2.3.2小節(jié),我們提到了UE的網絡同步方式為增量式的狀態(tài)同步,任何一刻的狀態(tài)數據都是前面所有狀態(tài)同步數據的疊加,所以必須從最開始播放才能保證不丟失掉中間的任何一個數據包。想要實現快進和時間跳躍必須通過加載最近的Checkpoint才能完成。
在每次開始回放前,我們可以給回放指定一個目標時間,然后回放系統就會創(chuàng)建一個FGotoTimeIn SecondsTask來執(zhí)行時間跳躍的邏輯?;舅悸肥窍日业礁浇囊粋€Checkpoint(快照點)加載,然后快速讀取從Checkpoint時間到目標時間的數據包進行解析。這個過程中有很多細節(jié)需要理解,比如我們從20秒跳躍到10秒的時候,20秒時刻的Actor是不是都要刪除?刪除之后要如何再去創(chuàng)建一個新的和10秒時刻一模一樣的Actor?不妨帶著這些問題去理解下面的流程。
1. FGotoTime InSecondsTask調用StartTask開始設置當前的目標時間,然后調用ReplayStreamer的GotoTimeInMS去查找要回放的數據流位置,這個時候暫?;胤诺倪壿嫛?/p>
2. 查找到回放數據流后,調用UDemoNetDriver:: LoadCheckpoint開始加載快照存儲點。
1)反序列化Level的Index,如果當前的Level與Index標記的Level不同,需要把Actor刪掉然后無縫加載目標的Level。
2)把一些重要的Actor設置成同步立刻處理AddNonQueued ActorForScrubbing,其他不重要的Actor同步數據可以排隊慢慢處理(備注:由于在回放的時候可能會立刻收到大量的數據,如果全部在一幀進行反序列并生成Actor就會導致嚴重的卡頓。所以我們可以通過AddNonQueued ActorForScrubbing/AddNonQueued GUIDForScrubbing設置是否延遲處理這些Actor對應的二進制數據)。
3)刪除掉所有非StartUp(StartUp:一開始擺在場景里的)的Actor,StartUp根據情況選擇性刪除(在跳轉進度的時候,整個場景的Actor可能已經完全不一樣了,所以最好全部刪除,對于擺在場景里面的可破壞墻,如果沒有發(fā)生過變化可以無需處理,如果被打壞了則需要刪除重新創(chuàng)建)。
4)刪除粒子。
5)重新創(chuàng)建連接ServerConnection,清除舊的Connection關聯信息(雖然我們在剛開始播放的時候創(chuàng)建了,但是為了在跳躍的時候清理掉Connection關聯的信息,最好徹底把原來Connection以及引用的對象GC掉)。
6)如果沒有找到CheckpointArchive(比如說游戲只有10秒,Checkpoint每30秒才錄制一個,加載5秒數據的時候就取不到CheckpointArchive)。
7)反序列化Checkpoint的時間、關卡信息等內容,將CheckpointArchive里面的回放數據讀取到FPlaybackPacket數組。
8)重新創(chuàng)建那些被刪掉的StartUp對象。
9)獲取最后一個數據包的時間用作當前的回放時間,然后根據跳躍的時長設置最終的目標時間(備注:比如目標時間是35秒,Checkpoint數據包里面最近的一個包的時間是30.01秒。那么還需要快進跳躍5秒,最終時間是35.01秒,這個時間必須非常精確)。
10)解析FPlaybackPacket,反序列所有的Actor數據。
3. 加載完Checkpoint之后,接下來的一幀TickDemoPlayback會快速讀取數據直到追上目標時間。同時處理一下加載Checkpoint Actor的回調函數。
4. 回放流程繼續(xù),TickDemoPlayback開始每幀讀取StreamArchive里面的數據并進行反序列化。
Checkpoint的加載邏輯里面,既包含了時間跳轉,也涵蓋了快進的功能,只不過這個快進速度比較快,是在一幀內完成的。
除此之外,我們還提到了回放的暫停。其實暫停分為兩種,一種是暫?;胤艛祿匿浿?讀取,通過UDemoNetDriver:: PauseRecording可以實現暫?;胤诺匿浿?,通過PauseChannels可以暫停回放所有Actor的表現邏輯(一般是在加載Checkpoint、快進、沒有數據讀取時自動調用),但是不會停止Tick等邏輯執(zhí)行。另一種暫停是暫停Tick更新(也可以用于非回放世界),通過AWorldSetting:: SetPauserPlayerState實現,這種暫停不僅會停止回放數據包的讀取,還會停止WorldTick的更新,包括動畫、移動、粒子等,是嚴格意義上的暫停。

3.5 回放系統的跨版本兼容
3.5.1 回放兼容性的意義
回放的錄制和播放往往不是一個時機,玩家可能下載了回放后過了幾天才想起來觀看,甚至還會用已經升級到5.0的游戲版本去播放1.0時下載的回放數據。因此,我們需要有一個機制來盡可能地兼容過去一段時間游戲版本的回放數據。
先拋出問題,為什么不同版本的游戲回放不好做兼容?

答:因為代碼在迭代的時候,函數流程、數據格式、類的成員等都會發(fā)生變化(增加、刪除、修改),游戲邏輯是必須要依賴這些內容才能正確執(zhí)行。舉個例子,假如1.0版本的代碼中類ACharacter上有一個成員變量FString CurrentSkillName記錄了游戲角色當前的技能名字,在2.0版本的代碼時我們把這個成員刪掉了。由于在1.0版本錄制的數據里面存儲了CurrentSkillName,我們在使用2.0版本代碼執(zhí)行的時候必須得想辦法繞過這個成員,因為這個值在當前版本里面沒有任何意義,強行使用的話可能造成回放正常的數據被覆蓋掉。
其實不只是回放,我們日常在使用編輯器等工具時,只要同時涉及到對象的序列化(通用點來講是固定格式的數據存儲)以及版本迭代就一定會遇到類似的問題,輕則導致引擎資源無效重則發(fā)生崩潰。
3.5.2 虛幻引擎的回放兼容方案
在UE的回放系統里面,兼容性的問題還要更復雜一些,因為涉及到了虛幻網絡同步的實現原理。
第一節(jié)我們談到了虛幻有屬性同步和RPC兩種同步方式,且二者都是基于Actor來實現的。在每個Actor同步的時候,我們會給每個類創(chuàng)建一個FClassNetCache用于唯一標識并緩存他的同步屬性,每個同步屬性/RPC函數也會被唯一標識并緩存其相關數據在FFieldNetCache結構里面。由于同一份版本的客戶端代碼和服務器代碼相同,我們就可以保證客戶端與服務器每個類的FClassNetCache以及每個屬性的FFieldNetCache都是相同的。這樣在同步的時候我們只需要在服務器上序列化屬性的Index就可以在客戶端反序列化的時候通過Index找到對應的屬性。

這種方案的實現前提是客戶端與服務器的代碼必須是一個版本的。假如客戶端的類成員與服務器對應的類成員不同,那么這個Index在客戶端上所代表的成員就與服務器上的不一致,最終的執(zhí)行結果就是錯誤的。所以對于正常的游戲來說,我們必須要保持客戶端與服務器版本相同。但是對于回放這種可能跨版本執(zhí)行的情況就需要有一個新的兼容方案。
思路其實也很簡單,就是在錄制回放數據的時候,把這個Index換成一個屬性的唯一標識符(標識ID),同時把回放中所有可能用到的屬性標識ID的相關信息(FNetFieldExport)全部發(fā)送過去。

通過下圖的代碼可以看到,同樣是序列化屬性的標識信息,當這個Connection是InteralACk時(即一個完全可靠不會丟包的連接,目前只有回放里面的DemonetConnection符合條件),就會序列化這個屬性的唯一標識符NetFieldExportHandle。

雖然這種方式增加了同步的開銷和成本,但對于回放系統來說是可以接受的,而且回放的整個錄制過程中是完全可靠的,不會由于丟包而發(fā)生播放時導出數據沒收到的情況。這樣即使我新版本的對象屬性數量發(fā)生變化(比如順序發(fā)生變化),由于我在回放數據里面已經存儲了這個對象所有會被序列化的屬性信息,我一定能找到對應的同步屬性,而對于已經被刪掉的屬性,我回放時本地代碼創(chuàng)建的FClassNetCache不包含它,因此也不會被應用進來。

從調用流程來說,兼容性的屬性序列化走的接口是SendProperties_ BackwardsCompatible_r/ReceiveProperties_ BackwardsCompatible_r,會把屬性在NetFieldExports里面標識符一并發(fā)送。而常規(guī)的屬性同步序列化走的接口是SendProperties_r/ReceiveProperties_r,直接序列化屬性的Index以及內容,不使用NetFieldExports相關結構。

到這里,我們基本上可以理解虛幻引擎對回放系統的向后兼容性方案。然而即使有了上面的方案,我們其實也只是兼容了類成員發(fā)生改變的情況,保證了不會由于屬性丟失而出現邏輯的錯誤執(zhí)行。但是對于新增的屬性,由于原來存儲的回放文件里面根本不存在這個數據,回放的時候是完全不會有任何相關的邏輯的。因此,所謂回放系統的兼容也只是有一定限制的兼容,想很好地支持版本差異過大的回放文件還是相對困難許多的。
更多內容,請關注:
《Exploring in UE4》Unreal回放系統剖析(下)
這是侑虎科技第1367篇文章,感謝作者Jerish供稿。歡迎轉發(fā)分享,未經作者授權請勿轉載。如果您有任何獨到的見解或者發(fā)現也歡迎聯系我們,一起探討。
作者主頁:https://www.zhihu.com/people/chang-xiao-qi-86
【USparkle專欄】如果你深懷絕技,愛“搞點研究”,樂于分享也博采眾長,我們期待你的加入,讓智慧的火花碰撞交織,讓知識的傳遞生生不息!