當前位置: 華文問答 > 遊戲

為什麽大多數遊戲的回放功能都不能倒放或者跳躍進度?

2019-01-20遊戲

說起這個問題,這背後藏著一個很深的遊戲開發學問,只是一直沒有人去深究它,因為這個需求本身不是核心需求,而且是一個可以很容易推脫掉的需求,配合「只要做到it just works就行」的心態,外加又不是「最賺錢」的「渲染學」,所以沒人樂意研究它。但恰恰是這麽一個需求,當年讓我的遊戲開發水平又升了一級。出於這種特殊情感,我又要發泄一篇帶吐槽、帶技術的長篇大論了。 從實作原理的角度來看,這個功能為什麽大多遊戲沒有實作

首先當然是吐槽

之所以一直做不到這個, 是因為通常我們面對「重播」這個功能,都 采用小聰明的「笨辦法」 去做。最常見的是記錄整個遊戲過程的輸入(input,包含玩家操作以及AI的指令等,如有必要的話)或者把一段時間內顯卡渲染的資訊記錄下來。

記錄輸入,或者更高級一點是記錄當時遊戲行程的狀態資訊(Snapshot) :這個做法,最終實作的是讓遊戲邏輯快速重新演算到「開始幀」(即玩家希望從這個時間點開始看起),然後繼續演算下去,重新依賴整個遊戲的邏輯部份不斷重新產生數據,然後「重玩」了一局遊戲,做到「重播」,是不是我這麽說穿了,你就感覺是在湊效果?因為聽起來似乎哪兒不對勁,有點不靠譜,但是呢,他又總是能執行(就跟「一本正經的胡說八道」一樣,沒毛病,沒法證偽,只能認為這是對的)。 但事實上這種做法在遭遇「倒推」需求的時候就非常操蛋了,因為你記錄的Input都是時間順序的,假如使用者要求倒過來,你根本沒法逆轉所有的input結果 。事實上,這裏暴露出一個致命的問題—— 就是這麽做的人,根本沒想過「渲染依賴的數據非得是遊戲邏輯產生的嗎?」這個問題,當年的我也是這樣,還自以為做到了數據邏輯分離,雖然看似很接近了,但是失之毫厘,差之千裏 ,如果你有耐心聽完我吐槽,看下去深入了解這個需求的原理,你就能知道為什麽我這麽說。

記錄顯卡渲染數據做法 :事實上是絕對正確的做法,就像很多錄制視屏的軟體的做法一樣,是將一段時間內顯卡的數據記錄下來,用以重新傳回給顯卡實作重播效果。包括Switch的分享按鈕長按等,很多都是這麽實作的,嚴格來說,這樣做是對的,真正的「錄像」就應該如此。但是問題在於,且不說數據量之大,我們真實的需求,真的是做一個「錄像功能」嗎?當然, 只要不在乎一段遊戲的視訊占用過大的硬碟,那麽這樣「錄像」的效果,倒推、跳著播是絕對沒有問題的

這就是為什麽你很少能看到一個遊戲的「錄像」功能,能夠完善到除了常見功能,還有如倒過來播放、跳躍進度等「常見功能」。 因為這兩種做法,都存在這根本的問題,實作上都有對遊戲產品來說不科學的地方,實作的思想上也根本沒有重視這個問題

接下來是討論和思考時間,探索一下這個功能的原理

開始思考這個問題之前,你可能有必要先了解一下ECS是什麽,我用最通俗的方式表達過一次:

我們重新從程式角度思考一下策劃的這個「重播需求」,用Pascal一言以蔽之就是,我們需要一個:

procedure TGameRecord . Render ( frameIndex : integer );

它的作用是是在「重播」模組下,讓畫面渲染指定邏輯幀(frameIndex)的情況,只要實作了這個功能,並且執行高效(肯定不能是重新演算一下這麽低效把),那麽真正的「視訊錄像」功能也就算是完成了(至少是核心功能完成了吧,UI什麽的當然還沒做)。那麽這個函式裏到底要做些什麽事情呢?

最早接到做一個完整的視訊功能,並且甲方高度重視這個功能,是在2014年,當時直播行業剛起步(萌芽階段),甲方的意圖是,如果遊戲能夠更方便使用者去錄制各種視訊,包括一些好像是叫「鬼畜」的功能,就是玩家可以選定一段然後讓這段來回播放什麽的。所以需求中,的確存在了倒播、甚至是復制和插播的功能。而那個遊戲玩一局的時間,短的差不多5分鐘,長一點可能得2小時(甲方設計的確傻逼,但是作為乙方在提議無效的情況下,是只能當做正確的需求來做的,這裏順便吐槽一下很多公司的程式設計師,其實是「甲方程式設計師」,甚至可以否決策劃設計,這工作也太輕松了點了吧)。 其實一切邏輯你也想得到,就像上面這個問題的答案一樣「就是把frameIndex下的數據拿出來渲染」,說這句話非常輕松,但是只要批判一下,你就發現一個很嚴重的問題 ——那麽每一幀要記錄的數據是什麽?要知道長達2小時的遊戲局(如果使用者當中掛機一會甚至可以玩6、7個小時一局,算了這就不吐槽了),即使FPS是30,也要14400幀的數據,如果按照每一幀都是一個位圖來存,這顯然是沒法接受的;但是如果我只記錄操作,那麽倒播是根本無法實作的,這不符合甲方的需求,正如吐槽裏說的。 那麽當時最先想到的最佳的方案就是,記錄每一幀的Snapshot——這是這兩個方案的折中方案

但是如果順著這個Snapshot的思路繼續下去,你就會問出下一個問題——那麽我要存些什麽數據呢?最早進入我大腦的方案是這個:

因為既然客戶端可以「重播」一段來自伺服器的數據,那麽它重播自己的數據,本身沒有問題,並且這樣一套機制,看起來是通用的,適合於任何其他遊戲。當時我也沈迷於造輪子,追求的是一個機制可以用在所有的遊戲當中,這也是使用ECS的目的之一。但是在實際動手寫程式碼(Haxe寫ECS)的時候,新的問題產生了,讓我不得不重新思考這個問題:

是不是我之後開發的每一個遊戲,我針對這個遊戲產生這些「重播數據」,以及使用這些「重播數據」,都得有一個約定的寫法?或者說每開發一個遊戲,都得為它的「重播數據」客製一套「重播功能」?

雖然主要工作是遊戲策劃,但是我對於編程還是有一定的要求的,原則告訴我,ECS需要的不是這個玩意兒,不是一份說明書,不是一個約定,也不是一個應付的玩意兒,它真正需要的是:

一個RecordSystem和一個RecordComponent,來實作一個:不依賴於遊戲邏輯產生數據,並且能夠重播,並實作TGameRecord.Render()的這麽個玩意兒。或者換句話說,如果它本身是一段「錄像」,是否能被「重播」?(是不是感覺這個需求說起來有點繞了?)

然後我根據這個真實的需求,仔細思考了一下實作的細節,它應該是……

RecordComponent

在這個Component中的只有2個數據:currentValue:Array<ComponentKeyValue>和modifications: Array<ComponentModification>,這玩意記錄了這個RecordComponent的宿主Entity下,所有position和render這兩個Component中的數據變化。

ComponentKeyValue的內容包括:

  • component:string,之所以用string,而不是用Component,是因為Component之間是不應該存在互相依賴的關系,所以使用一種類似「密碼」的方式來實作自欺欺人的效果,是的,大多程式設計師都善於自欺欺人,比如C#裏的單例模式(當然這就扯遠了)。這個的值就是Component的名字,這是在ECS裏能正常Get得到的(簡單地說就是.toString()而已)。
  • key:string,對應Component中的內容,跟component一樣道理。
  • value:dynamic,對應的值,可以吃haxe的dynamic類的糖。
  • 這些值在初始化這個component的時候,可以從對應的Component裏讀取,實際上這個東西也是為了實作「對比」功能而存在的,當然為了重播,可以再開一個initValue,即建立時候所關心的Component的數據,並且不再改動,用於錄像功能更方便的追溯當前幀的數據。

    ComponentModification的內容包括:

  • tick:int,即這次改動所發生的遊戲tick,確切的說是RecordSystem運作了的tick數。
  • modification: ComponentKeyValue,即內容變化的樣子資訊,那個內容發生了變化,變成了什麽。
  • 透過這樣一個array,我就可以知道這個entity的「有效生命周期」裏,它的renderSystem所依賴的數據(Component)發生的變化。

    RecordSystem

    這個系統關註的Component有:

  • RecordComponent:即上面的Component。
  • Render和Position:之所以只關心這兩個,是因為大多遊戲中RenderSystem關註這兩個就足夠了,但是可能存在第三個,這個當初沒有完成歸納,不幸的是團隊解散了,各奔東西了,ECS也被雪藏了,直到很多年後守望先鋒再次提起,當然這是另外一個故事了。
  • 這個System的工作核心有2件事情:

    1. 記錄每次執行時,所捕捉到的entity,整理後歸納到一個臨時檔,這個臨時檔中,實際記錄的是一個entity被「建立」即首次被捕獲的tick,和被「移除」的tick,即不再捕捉到這個entity,當然實作方式很多種,這是一個不咋得的方式,但是當時也沒來得及最佳化,it just works。
    2. 對比RecordComponent中的currentValue和對應的Component中的對應內容,如果發生變化了,則要在RecordComponent中記錄,並重新整理對應的內容。這樣一來,在整個RecordComponent生命周期中,「發生過的變化」都被一一記下了。

    「重播數據」產生及套用

    一局遊戲結束後,即這個System被整個遊戲世界Shutdown的時候,匯出數據,這個數據中主要包的內容是:一個RecordComponent被建立和移除的時間點(tick),以及被移除時的數據。透過這些數據資訊,我們就可以實作這個TGameRecord.Render:

    1. 首先獲得當前幀所有的entity:這個entity和遊戲執行時候的entity不同,ECS的特性也是如此,即我們人理解的一個東西,他未必只是一個entity,他可以是很多個「同步的」entity,當然這是一個ECS的使用方式問題了,這裏不細說。從數據中,我們能得出當前幀存在的RecordComponent的陣列,有多少個存在的RecordComponent,就有多少個entity。
    2. 為entity建立render和position兩個Component,這裏你很容易就能從RecordComponent中過濾出當前幀的內容資訊來,不論你是用RecordComponent.initValue還是用currentValue,只是一個for迴圈的方向問題而已。
    3. 透過一個RecordRenderSystem來把他們繪制出來。

    這個「重播功能」的核心部份就算是完成了。

    總結

    耐心看到這裏,你可以發現,實際上一個「完善的重播功能」實作方式是這樣的,與我們「小聰明產生的笨辦法」看起來相似,但實際上差距還是存在的——它既不是存顯卡資訊,也不是存操作指令;它看起來存的是snapshot(或者說它存的就是snapshot),但是此snapshot非彼snapshot,並且這才是泛用型的「重播功能」,因為我可以把這個RecordSystem和RecordComponent用於任何遊戲,而不用單獨寫什麽額外邏輯,唯一可能需要維護的,就是關心的Component而已(但這講道理也是不該變化的)。

    既然實作方式不同,自然根本就不是一個東西,所以你提的需求,和現在市面上遊戲所做的東西,就不是一個東西,他們滿足不了你的這些倒播、跳播,自然也是很正常的事情了