本篇分為上下兩篇,上篇內(nèi)容請關(guān)注:
《Exploring in UE4》Unreal回放系統(tǒng)剖析(上)
四、死亡回放/精彩鏡頭功能的實現(xiàn)
在FPS游戲里,一個角色被擊殺之后,往往會以敵方的視角回放本角色被定位、瞄準、射擊的過程,這就是我們常提到的死亡回放(DeathCameraReplay)。類似的,我們在各種體育游戲里面經(jīng)常需要在一次得分后展示精彩瞬間,這種功能一般稱為精彩鏡頭。

上一節(jié)案例使用的是基于本地文件存儲的回放系統(tǒng),每次播放時都需要重新加載地圖。那有沒有辦法實現(xiàn)類似實況足球的實時精彩回放呢?有的,那就是基于DuplicatedLevelCollection和內(nèi)存數(shù)據(jù)流的回放方案。
思考一下,通常射擊游戲里的擊殺鏡頭、體育競技里的精彩時刻對回放的基本需求是什么?這類回放功能往往是在某個時間點可以無感知地立刻切換到回放鏡頭,并在回放結(jié)束后迅速再切換到正常的游戲環(huán)境。同時,考慮到聯(lián)機的情況,我們在回放時要保持游戲世界的正常運轉(zhuǎn),從而確保不錯過任何服務(wù)器的同步信息,不影響其他玩家。
簡單總結(jié)就是:
1. 可以迅速地在真實游戲與回放鏡頭間切換
2. 回放的時候不會影響真實游戲里面的邏輯變化
4.1 回放場景與真實場景分離
為了實現(xiàn)上述的要求,我們需要將回放的場景和真實的場景進行分離,在不重新加載地圖的情況下快速地進行切換。虛幻引擎給出的方案是對游戲世界World進行進一步的拆分,把所有的Level組織到了三個LevelCollection里面,分別是:
- DynamicSourceLevels,存儲真實世界的所有標記為Dynamic的Level(包含里面的所有Actor)
- StaticLevels,存儲了靜態(tài)的Actor,也就是回放過程中不會發(fā)生變化的對象,通常指那些不可破壞建筑(通過關(guān)卡編輯器里面的Static選項,可以設(shè)置任何一個SubLevel是屬于DynamicSourceLevels還是StaticLevels的,PersistLevel永遠是Dynamic的)
- DynamicDuplicatedLevels,回放世界的Level(包含里面的所有Actor),會把DynamicSourceLevels里面的所有Level都復制一遍


在游戲地圖Loading的時候,我們就會把這三種LevelCollection全部構(gòu)建并加載進來(可以通過Experimental_ShouldPreDuplicateMap來決定某張地圖是否可以復制Level到DynamicDuplicatedLevels),這樣在進行回放的時候我們只要控制LevelCollection的顯示和隱藏就可以瞬間對真實世界和回放世界進行切換了。

判斷一個對象是否處于回放世界(DynamicDuplicatedLevels)也很簡單。

要注意的是,由于LevelCollection的引入,原來很多邏輯都變得復雜了。
1. 不同LevelCollection的Tick是有先后順序的,默認情況下是按照他們在數(shù)組的排列順序DynamicSourceLevels-> StaticLevels-> DynamicDuplicatedLevels,這個順序可能影響我們的代碼邏輯或者攝像機更新時機。
2. 回放世界DynamicDuplicatedLevels里面也會有很多Actor,如果不加處理的話很有可能也被錄制到回放系統(tǒng)中,造成嵌套錄制。
3. 當一個DynamicDuplicatedLevels執(zhí)行Tick的時候,會通過FScopedLevelCollectionContextSwitch來切換當前的ActiveCollection,進而修改當前World的GameState等指針,所以在回放時需要注意獲取對象的正確性。(比如下圖獲取PC的迭代器接口,在DuplicatedLevels Tick時只能獲取到回放世界的PC)。
4. 用于回放的UDemoNetDriver會綁定一個LevelCollection(通過傳入PlayReplay的參數(shù)LevelPrefixOverride來決定)。當觸發(fā)回放邏輯后,即UDemoNetDriver::TickDispatch每幀解析回放數(shù)據(jù)時,我們也會通過FScopedLevelCollectionContextSwitch主動切換到當前DemoNetDriver綁定的LevelCollection,保證解析回放數(shù)據(jù)時可以通過Outer找到回放場景(DynamicDuplicatedLevels)


4.2 回放錄制與播放分離
考慮到在死亡回放的時候不會影響正常比賽的進行和錄制,所以我們通常也需要講錄制邏輯與播放邏輯完全分離。
簡單來說,就是創(chuàng)建兩個不同的Demonetdriver,一個用于回放的錄制,另一個用于回放的播放。在游戲一開始的時候,就創(chuàng)建一個DemonetdriverA來開始錄制游戲,當角色死亡觸發(fā)回放的時候,這時候創(chuàng)建一個新的DemonetdriverB來進行回放數(shù)據(jù)的讀取并播放,整個過程中DemonetdriverA一直在處于錄制狀態(tài),不會受到任何影響。(需要我們手動重寫GameInstance::PlayReplay函數(shù),因為默認的邏輯每次創(chuàng)建一個新的Demonetdriver就會刪掉原來的那個。)

4.3 基于內(nèi)存的回放數(shù)據(jù)流
當然,想要實現(xiàn)真正的快速切換,只將回放場景與真實世界的分離還不夠,我們還需要保證回放數(shù)據(jù)的加載也能達到毫秒級別。所以這個時候就不能再使用前面提到的LocalFileNetworkReplayStreamer把數(shù)據(jù)放到磁盤上,正確的方案是采用基于內(nèi)存數(shù)據(jù)流的ReplayStreamer來加快回放數(shù)據(jù)的讀取。下面是InMemoryNetworkReplayStreamer對回放數(shù)據(jù)的組織方式,每幀的數(shù)據(jù)流會根據(jù)時間分段存儲在StreamChunks里面,而不同時間點的快照則會存儲在Checkpoints數(shù)組里面。對于射擊游戲,我們通常會在比賽一開始就執(zhí)行錄制,錄制的數(shù)據(jù)會不斷寫到下面的結(jié)構(gòu)里面并在整場比賽中一直保存著,當玩家被擊殺后就可以立刻從這里取出數(shù)據(jù)來進行回放。



關(guān)于死亡回放/精彩鏡頭其實還有很多細節(jié)問題,這里列舉一些(最后一節(jié)會給出一些建議):
- 引擎編輯器里面默認不支持DynamicDuplicatedLevels的創(chuàng)建,所以在不改源碼的情況下無法在編輯器里面實現(xiàn)死亡回放功能。
- 回放世界與真實世界都是存在的,可以通過SetVisible來處理渲染,但是回放世界的物理怎么控制?
- 回放世界默認情況下不會復制Controller(容易和本地的Controller發(fā)生沖突),所以很多相關(guān)的接口都不能使用。
- 由于不同Collection的Tick更新時機不同,但是Controller只有一個,所以回放的時候要注意Controller的更新時機。
- 默認的錄制邏輯都是在本地客戶端實現(xiàn)的,可能對客戶端有一定的性能影響。
更多細節(jié)建議到GitHub參考虛幻競技場的源碼:
五、Livematch觀戰(zhàn)系統(tǒng)
在CSGO、Dota、堡壘之夜等游戲里,都支持玩家觀戰(zhàn)的功能,即玩家可以通過客戶端直接進入到某個正在進行的比賽的場景里進行實時觀戰(zhàn)。不過一般情況下并不是嚴格意義上的完全實時,通常根據(jù)情況會有一定程度的延遲。
實現(xiàn)該功能的一個簡易方案就是讓觀戰(zhàn)的玩家作為一個客戶端連接進去,然后實時地接受服務(wù)器同步數(shù)據(jù)來進行觀戰(zhàn)。這種方式既簡單,效果也好,但是問題也非常致命——觀戰(zhàn)的玩家可能會影響正常服務(wù)器性能,無法很好地支持大量的玩家進入。

所以大部分的游戲?qū)崿F(xiàn)的都是另一種方案,即基于Webserver和回放的觀戰(zhàn)系統(tǒng)。這種方案的思路如下圖,首先我們需要專門搭建一個用于處理回放數(shù)據(jù)的WebServer,源源不斷地接收來自GameServer的回放錄制數(shù)據(jù)。然后客戶端在請求觀戰(zhàn)時不會去連接GameServer,而是直接通過Http請求當前需要播放的回放數(shù)據(jù),從WebServer拿到數(shù)據(jù)后再進行本地的解析與播放。雖然會有一定的延遲,但是理想情況下效果和直接連入戰(zhàn)斗服觀戰(zhàn)是一樣的。

前面我們提到過基于Httpstream的數(shù)據(jù)流,正是為這種方案而實現(xiàn)的。去仔細看一下FHttpNetworkReplayStreamer的接口實現(xiàn),都是通過Http協(xié)議對回放數(shù)據(jù)進行封裝而后通過固定的格式來發(fā)給WebServer的(格式可以按照需求修改,和WebServer的代碼要事先規(guī)定統(tǒng)一)。

六、性能優(yōu)化/使用建議
前面我們花了大量的篇幅,由淺入深地講解了回放系統(tǒng)的概念以及原理,而后又對兩個具體的實踐案例(死亡回放、觀戰(zhàn)系統(tǒng))做了進一步的分析,希望這樣可以幫助大家更好地理解UE乃至其他游戲里面回放系統(tǒng)的思想思路。
文章的最后,我會根據(jù)個人經(jīng)驗給大家分享一些使用建議:
如果想創(chuàng)建自定義的DemonetDriver,需要在配置文件里面:

- 回放的錄制既可以在客戶端也可以在服務(wù)器。
- 在回放中同步Controller要慎重,如果是在客戶端錄制回放數(shù)據(jù)最好不要同步Controller,因此玩家相關(guān)同步數(shù)據(jù)也最好不要放在Controller里面(PS代替)。
- RPC由于沒有狀態(tài),所以很容易在回放里面丟失掉,對于有持續(xù)狀態(tài)的同步效果(比如播放一個比較長的動畫、道具的顯示隱藏等),不要用RPC做同步(改為屬性同步)??偟膩碚f,整個項目代碼里面都要克制地使用RPC。
- 死亡回放涉及到Level的拷貝,這會明顯增大游戲的內(nèi)存使用,對于那些在回放中不會發(fā)生變化的物體(比如Staticmesh的墻體),一定要放置到StaticLevels里面。
- 播放回放時會預先多加載5秒左右的數(shù)據(jù)(MAX_PLAYBACK_ BUFFER_SECONDS),在觀戰(zhàn)系統(tǒng)里面要注意這個間隔,如果Http發(fā)送不及時就很容易造成卡頓。
- 回放里面很多NetStartActor的邏輯都是通過資源路徑來定位的,使用不當很容易造成一些資源引用、垃圾回收以及資源查找的問題。舉個例子,比如我們刪除了一個NetStartActor對象(已經(jīng)標記為Pendingkill了),但是通過StaticFindObject我們?nèi)匀荒懿榈竭@個對象,這時候如果再拿這個路徑去生成Actor就會報錯并提示場景里面已經(jīng)有一個一模一樣的Actor了。
- Checkpoint的加載可能會造成性能問題,可以考慮分幀去處理。
- 回放有很多加載和生成對象的邏輯,很容易造成卡頓,建議項目內(nèi)自己維護一個對象池來優(yōu)化。
- 死亡回放結(jié)束的時候一定要及時清理回放數(shù)據(jù),否則可能造成內(nèi)存的持續(xù)增加,也可能造成一些殘留的Actor對功能造成影響。
- 回放世界和真實世界是同一個物理場景,需要避免碰撞。
- 盡量避免在回放世界打開物理。
- 通過設(shè)置PxFilterFlags并修改引擎的碰撞規(guī)則處理。
- 序列化的操作要注意很多細節(jié),比如結(jié)尾處是不是一個完整的字節(jié)。很多奇怪的Check在網(wǎng)絡(luò)部分的崩潰八成都是序列化反序列化沒有匹配造成的。
- 臨時拷貝盡量使用全局Static,對于較大的數(shù)據(jù),一定要壓縮,效果明顯。
這是侑虎科技第1367篇文章,感謝作者Jerish供稿。歡迎轉(zhuǎn)發(fā)分享,未經(jīng)作者授權(quán)請勿轉(zhuǎn)載。如果您有任何獨到的見解或者發(fā)現(xiàn)也歡迎聯(lián)系我們,一起探討。
作者主頁:https://www.zhihu.com/people/chang-xiao-qi-86
【USparkle專欄】如果你深懷絕技,愛“搞點研究”,樂于分享也博采眾長,我們期待你的加入,讓智慧的火花碰撞交織,讓知識的傳遞生生不息!