驅動遊戲世界運轉的“心跳”:遊戲迴圈及實時模擬

遊資網發表於2021-08-20
以下文章來源於騰訊遊戲學院 ,作者騰訊遊戲學院

“和學霸一起學”欄目推送遊戲相關的專業課程內容,通過相對專業、體系的知識內容,幫助大家提升對遊戲的認識水平和理解力。本篇內容源於由清華大學美術學院與騰訊遊戲學院聯合制作“遊戲程式設計”系列課程,課程名稱為《遊戲迴圈及實時模擬》(講師:蘭翔)。

本文通過遊戲迴圈概述、遊戲計時機制與遊戲迴圈驅動的主要子系統的介紹,並結合demo遊戲《無盡之路》,具體講解各子系統與子系統開發的方法。為了方便理解,《無盡之路》按照一種比較大的遊戲方式做了架構,這個demo遊戲可以在http://github.com/dreamanlan/GameDemo上檢視。

驅動遊戲世界運轉的“心跳”:遊戲迴圈及實時模擬

《無盡之路》基本上沒有什麼美術資源,大都是Unity自帶的模型,所以看起來比較簡陋。它設計了一個地形,上面擺了很多小球,而玩家在地面上移動,而幾個AI跟在玩家身後,在這個場景中大概有兩千多個小球。遊戲規則也比較簡單:玩家通過滑鼠操作移動遊戲角色,遊戲角色走到哪個地方,上面的球就會掉落,玩家被砸到就會掉血。

#01 遊戲迴圈概述

各類程式及其特點

驅動遊戲世界運轉的“心跳”:遊戲迴圈及實時模擬

常見的程式,第一類是面向任務的被動處理軟體,沒有任務就可以休眠。一般包括:命令列程式、GUI(圖形使用者介面)程式、作業系統服務、WEB服務、MIS(管理資訊)系統、ERP(企業資源規劃)系統以及進銷存系統和財務軟體等財務系統。這類軟體的主要特點,是被動處理。只有去請求一個功能時,才需要進行處理。如果不做請求,就不需要做什麼,自動休眠。

驅動遊戲世界運轉的“心跳”:遊戲迴圈及實時模擬

第二類,是自動控制軟體。在給定設定值後,這類軟體就會通過調節器,進行自動調節。這類軟體的特點,是主動處理,不能停止執行。特別是在工業控制中,如果軟體停下來,現場可能就會失控。

驅動遊戲世界運轉的“心跳”:遊戲迴圈及實時模擬

而遊戲程式,相對於上面兩類軟體,有自己的獨特之處。遊戲最典型的特點是,遊戲世界是對真實世界的模擬。即使是小型遊戲,比如棋牌類遊戲,也是對現實世界棋牌的模擬。大型遊戲,比如最典型的RPG(角色扮演類遊戲),則是完完全全在模擬真實世界。

此外,遊戲世界和真實世界類似的地方在於,即使玩家不做任何操作,遊戲世界還是會正常運轉。正是由於這個特點,遊戲需要主動運轉,而主動運轉整個世界的驅動,是在遊戲迴圈中進行的。

遊戲迴圈的作用及層次

在遊戲行業,遊戲的迴圈被稱為“心跳”。遊戲迴圈主要有三個作用:

①驅動遊戲世界的運轉。遊戲在每一次“心跳”時,都會進行相應處理,來驅動整個遊戲世界的運轉。

②驅動遊戲中NPC的行為。NPC是非玩家角色,一般由AI控制。NPC的行為,也是靠迴圈驅動的。

③實現遊戲世界與玩家的互動。

而本文所講的遊戲迴圈,主要指前端的程式迴圈。一般分成兩個層次:遊戲引擎迴圈和遊戲邏輯迴圈,遊戲迴圈一般是由底層的引擎迴圈驅動的。

遊戲引擎迴圈

驅動遊戲世界運轉的“心跳”:遊戲迴圈及實時模擬

3D引擎的迴圈,其實是在模擬真實世界的攝像。相當於在遊戲世界中,有一個攝影師拿著相機在觀察這個世界。所以,在3D引擎中,攝像機是最主要的概念。

此外,3D引擎的概念,還包括可移動物件、場景和UI。可移動物件,指在遊戲中可以動的物件。場景,通常是遊戲中的遊戲世界,遊戲裡所有的動作和事件,都在這個場景裡發生。UI,通常在玩家和遊戲互動的過程中會涉及。

在遊戲中,引擎往往服務於相機、UI系統、場景管理、遊戲物件的管理和支援。

驅動遊戲世界運轉的“心跳”:遊戲迴圈及實時模擬

引擎的主迴圈,主要包括上圖的五個步驟。

首先是遊戲邏輯的迴圈。引擎裡會有一部分去驅動遊戲邏輯的迴圈。NPC、可移動物件位置的變動、屬性的變化等都存在於遊戲迴圈中。

其次,遊戲邏輯控制可移動物件的位置、旋轉、縮放等變化,進行Transform更新。當遊戲場景比較複雜時,可能會採用空間資料結構進行管理,涉及二叉樹或者八叉樹,以及相應資料結構的更新。

然後,是整個場景的渲染。場景渲染結束後是渲染UI,因為UI一般是覆蓋在場景表面上的。

最後,是雙緩衝的切換。一般在顯示一幀遊戲畫面時,另一幀在後臺就會提前寫好,這樣畫面的顯示就會比較連貫。

驅動遊戲世界運轉的“心跳”:遊戲迴圈及實時模擬

上圖左是搭建的一個場景,包含相機視角和各種物件的展示,圖右是螢幕中的呈現效果,它實際上是所有3D物件在相機近裁剪面上的投影。

遊戲邏輯迴圈

驅動遊戲世界運轉的“心跳”:遊戲迴圈及實時模擬

遊戲邏輯的迴圈是由引擎來驅動的,一般在引擎迴圈每一幀的開始或結束進行遊戲邏輯的處理,不會嵌在中間。因為引擎的整個渲染需要加鎖,如果在中間進行修改,可能會影響整個引擎的管線。

遊戲邏輯的迴圈主要是用來驅動遊戲的各個子系統,包括網路、場景、資源、遊戲物件、AI、戰鬥、劇情和UI等。其中,遊戲物件指玩家和NPC,AI主要用來控制NPC,戰鬥包括技能和BUFF等資訊。

遊戲邏輯迴圈一般有兩類風格:

一種是事件或訊息驅動,類似windows的訊息迴圈機制,更有設計特點;

另一種是靜態迴圈,依次呼叫各個子系統的Tick(即“心跳”),這種方式比較直白,容易理解。一般來說,遊戲邏輯迴圈更常採用靜態迴圈的方式。因為遊戲開發是一個多人協作的過程,而且遊戲開發的時間一般都比較長,可能會有人員的流動,所以保持簡單和可理解,對開發人員來說是比較重要的事情,而且,簡單對於產品質量的影響,比採用更好的技術可能會更大一些。而事件或訊息驅動的方式,編寫和除錯比較複雜,程式碼不易理解,所以不太常用。

遊戲迴圈分類

遊戲迴圈主要可以分為三種型別。

驅動遊戲世界運轉的“心跳”:遊戲迴圈及實時模擬

第一類,是Windows訊息主迴圈。以前在Windows上,都是GUI系統(Graphic User Interface,圖形使用者介面),Windows是訊息驅動的,所以遊戲的迴圈是在Windows訊息的主迴圈裡。相當於每一次Windows程式處理完訊息佇列裡的訊息,就會做一次遊戲的Tick。

驅動遊戲世界運轉的“心跳”:遊戲迴圈及實時模擬

第二類,是遊戲幀的回撥。圖中的是比較老的windows系統上的開源引擎——OGRE。它在每一幀的開始和結束有兩個回撥,中間是遊戲引擎的迴圈,渲染當前的場景。兩個回撥,實際是執行遊戲的邏輯,修改遊戲世界裡邏輯層的一些東西,中間通過引擎的方式表現出來。

驅動遊戲世界運轉的“心跳”:遊戲迴圈及實時模擬

第三類,是Unity3d。它把遊戲開發做的很簡單,允許開發者不用再去了解很底層的東西。它使用的是一個C#語言的指令碼,可以理解成一種事件驅動。但實際上Unity3d是在MonoBehaviour上,在C++裡直接呼叫了Awake、Start、Update和FixedUpdate這四個方法,而不是用C#的event機制。

上文提到過,遊戲迴圈是在驅動各個子系統。每一個子系統基本上都有一個“心跳”,迴圈就是依次對各個子系統“心跳”的呼叫。迴圈如果是靜態方式的話,可以很直觀地看到各個子系統的呼叫。如果是事件驅動的方式,那麼看到的是一個已經註冊好的列表迴圈Tick,這個迴圈會隨各子系統工作與否產生變化。比如,某些系統可能不需要工作,那它就可以從列表裡移除,Tick的時候就會少Tick一些,所以效能上可能會稍微好一點。但對於一般遊戲的系統,特別是對RPG遊戲,大多數子系統都是要一直工作的,所以兩種方式的差別很小。

遊戲裡的時間

驅動遊戲世界運轉的“心跳”:遊戲迴圈及實時模擬

遊戲裡的時間,一般有兩類:絕對時間和相對時間。關於相對時間,可以用Unity的Time scale屬性來解釋。這個屬性預設是1,當調成2、3或4時,遊戲程式就會變快,這種時間就是相對時間。遊戲都是通過心跳來驅動的,心跳本質上是對遊戲世界的模擬,這種模擬是基於時間的。相對時間代表了一個按照某種速度流逝的時間,時間流逝後就會產生對應的影響,所以如果把這個時間的速度加快,整個演算的速度就會變快。

相對時間涉及兩個概念,一個是加速播放,也稱為重演。比如,在王者中,如果玩家中間掉線了,重新連線上的時候就會看到,各種動作都會加快,這就相當於重演。

另一個是和重演相對應的反演,也叫時間倒流。反演最終可以無縫做到像錄影一樣,就是把每一幀都記下來,這樣就可以隨意跳到任何一幀。但反演的實現比較難,因為可能不是每一幀都能被記下,而是定期記錄一個快照與基於最近快照演算相結合。

假設某一幀裡有一個NPC被殺死,那它就消失了。如果沒有做任何記錄,然後根據玩家操作往回反演的話,到這一幀時,只知道有一個NPC消失了,但是恢復回來後發現這個NPC很多的資訊其實都沒有。這是因為遊戲演算的時候,每一幀都是對之前整個歷史資訊的積累,除非真的把NPC資訊記下來,否則,反演的難度是很大的。所以,擁有時間倒流功能的遊戲,一般來說技術上是比較先進的。

驅動遊戲世界運轉的“心跳”:遊戲迴圈及實時模擬

其次,是幀時間和實時時間。幀時間就是每幀的時間,迴圈裡是各種各樣的Tick,一般來說,取一幀的時間跟上一幀計算,這樣就能知道時間流逝了多久。遊戲裡的邏輯,很多時候是以每一幀時間作為步長去演算的。所以,很多時候在遊戲裡使用幀時間就夠了。

實時時間,就是真實時間,任何時候去取,取到的都是真實流逝到這個點的時間。遊戲邏輯的Tick裡會做很多運算,假設每秒鐘需要30幀,那麼每一幀最多隻能有30ms的時間,但如果運算一幀需要50ms,並且在這一幀裡把運算全部做完,那麼幀率會下降一半,所以需要把邏輯上的一些處理,進行分幀處理。把一個操作分到若干幀中去完成,就需要用到實時時間。在運算過程中,實時關注已經算了多久,按照預想的時間片,如果時間到了,就把運算先暫停一下,到後面幀再繼續處理。

驅動遊戲世界運轉的“心跳”:遊戲迴圈及實時模擬

最後,是高精度時間,它是和硬體相關的。高精度時間可以做到微秒級,主要用於效能分析。比如,遊戲執行很卡時,就要分析原因,需要去度量每一個函式的開銷,這時就需要用到微秒級時間。

此外,還有垂直同步這一概念。垂直同步在早期CRT的顯示器會用到,現在的硬體其實是不需要的。如上圖所示,CRT顯示器是靠電子槍在螢幕上一行一行快速掃過,讓這些點在螢幕上依次顯示。如果在電子槍掃的過程中,更新了幀快取,就會看到不同的顏色。比如在上圖中,假設快取更新,下面三條是藍色的,那看到的上一幀是上面五行紫色,下一幀就是下面三行藍色,就會出現幀的撕裂現象。為了解決這個問題,以前會等每一屏電子槍掃描完,然後讓電子槍回到螢幕左上角,這個過程就稱為垂直同步。為了做到每次更新都是在垂直同步的時候,遊戲幀率要跟顯示器幀率保持倍數關係,這樣的畫面顯示就不會有問題。

雖然現在一般沒有這個概念和需求,但在引擎裡依然保留了這種方式,比如Unity也提供了三十幀和六十幀的標準幀率,而現在移動遊戲的幀率一般是三十幀每秒。

遊戲迴圈的並行

驅動遊戲世界運轉的“心跳”:遊戲迴圈及實時模擬

我們前面都預設遊戲迴圈是由渲染迴圈來驅動的,但除了單執行緒之外,還有一種多執行緒的執行方式。只要是在渲染迴圈裡做一次驅動,比如啟動遊戲邏輯後,遊戲的迴圈就可以自己進行下去。

這種方式最典型的好處就是,可以將邏輯與引擎的執行緒進行分離。比如上文提到的分幀處理,如果遊戲迴圈跟渲染迴圈分開,那麼遊戲迴圈不會影響玩家看到的幀率,做一個幀率更穩定的遊戲就會比較容易。此時,遊戲迴圈和引擎能夠以不同的幀率去工作。比如,遊戲需要六十幀每秒的視覺效果,但遊戲邏輯只需要十幀每秒,這樣就可以通過並行的方式去做分離。

這種執行方式的缺點是比較複雜,遊戲的邏輯實際上是給引擎提供資料,對於並行這種方式來說,邏輯和引擎是在兩個執行緒裡,必然會涉及資料在兩個執行緒之間的同步問題,就會比較麻煩。

驅動遊戲世界運轉的“心跳”:遊戲迴圈及實時模擬

非同步程式設計的複雜性,最主要的一點就在於鎖。假設整個Tick加一個鎖,會導致不必要的等待,效能就會比較低,如果兩個執行緒的每一個Tick,都是一個鎖的話,就相當於被序列化,沒有多執行緒了。而鎖的粒度比較小,只鎖真正需要同步的那些執行緒時,邏輯上就會非常複雜,容易引起死鎖或活鎖。

不同軟體對於鎖的使用有些差別,在遊戲軟體裡,很少使用鎖的方式,因為遊戲的邏輯會比較複雜。傳統軟體一般會與某個行業相關,會有對應的業務模型。這個業務模型是趨於穩定的,長遠來看,業務中的很多東西,除了直接面向使用者的層面之外,到後期的變化就比較少。所以可以使用比較複雜的技術,因為這個技術不會被應用到所有的地方。

但對於遊戲軟體來說,遊戲所有的設計都是由策劃需求驅動的,策劃做遊戲有自己的追求,希望和別人做的不一樣,這與軟體工程的求穩是完全衝突的。而且,遊戲邏輯真的非常複雜,隨時都有可能變化,所以一般不會使用加鎖的方式對遊戲邏輯進行處理。

驅動遊戲世界運轉的“心跳”:遊戲迴圈及實時模擬

此外,在遊戲裡,如果涉及多執行緒,那麼會比較多地採用訊息佇列的方式。假設有四個執行緒和一個主執行緒,儘量保證每個執行緒執行過程中沒有其它的依賴,當有依賴時,就線上程之間加一些訊息佇列。訊息佇列本身是加鎖的,但因為這個鎖只需要鎖整個佇列,所以比較簡單,不需要在邏輯上加鎖。邏輯上,比如執行緒A需要和執行緒B通訊,就會發一個訊息,轉到訊息佇列裡進行加鎖,執行緒B就會按照自己的步調,從訊息佇列裡解鎖,讀出執行緒A發出的訊息,這樣的通訊方式和前文的加鎖是類似的。這種方式是比較標準的,Windows的訊息佇列也是以這種方式工作的。在遊戲裡,這種模式在後端會比較常見。

這樣的非同步操作,會有一個比較難處理的問題,當執行操作的資料由多個回撥提供時,程式碼會相對複雜難寫。在上圖中,假設有執行緒ABCD,此時,ABCD 各有一個操作,都在並行地執行,而主執行緒上有另外一個操作,這個操作必須要拿到ABCD全部的結果才可以執行。ABCD的操作結果都會發到訊息佇列,再由主執行緒從各個訊息佇列中取出,但取出的時間點肯定是不一樣的,有可能在第一、二、三、四幀分別取到A、B、C和D的結果。那麼,當主執行緒拿到A、B、C的結果時,是無法執行操作的,這就意味著前面三個操作的結果,都必須做快取,這是非同步程式設計會遇到的比較典型的問題。解決這種問題的方法,就是一旦涉及到操作的依賴,就必須將前面的操作結果進行快取,最後再去處理。這也是常用的命令式程式在非同步處理時的一個特點,對於這個問題,暫時沒有好的解決方案。

非同步程式設計的另一個複雜性,在於除錯和排錯困難。主要是因為,多執行緒程式的除錯需要設很多的斷點,因為線上程A裡單步除錯,是永遠不可能走到執行緒B裡去的。斷點的設定,就需要開發者自己去分析整個流程如何,而不像單步除錯那樣可以一步一步往下走,清晰簡單地瞭解整個資料流和控制流是怎樣的。

#02《無盡之路》的實現

《無盡之路》的玩法與規則

驅動遊戲世界運轉的“心跳”:遊戲迴圈及實時模擬

《無盡之路》這個遊戲demo比較簡單,用的都是Unity自帶的模型。《無盡之路》是在場景裡提前擺了兩千多個浮空的小球,玩家在場景中移動,一旦靠近小球,就會導致小球自由落體到地面產生爆炸,爆炸會對一定範圍內的玩家和NPC造成掉血傷害。玩家每觸發一個小球掉落,就會獲得一定的分數,但同時也會掉血。玩家沒血代表闖關失敗,玩家抵達中心的大球下方時,就會獲得最後的勝利。

《無盡之路》的架構

驅動遊戲世界運轉的“心跳”:遊戲迴圈及實時模擬

遊戲裡沒有特別明顯的架構層次,下文要介紹的是比較常用的一種方式。這裡的遊戲架構主要是針對前端來講的,因為前端基本沒有資料,主要做的是遊戲邏輯。

上圖所示的架構,可以理解為兩層。一層,是用一種語言去寫比較基礎的東西,包括通用元件和容器與子系統,另一層是用指令碼語言寫的指令碼。這種架構層次的處理,主要是為了適應變化。為了儘可能適應變化,開發者使用指令碼語言,然後其它部分寫得儘量可重用。

最下層,是容器和子系統。其中,容器是為了更貼合現在比較流行的業務系統的概念,一般更強調的是子系統,即上文提及的遊戲迴圈驅動的幾個系統。遊戲的業務就是體現在子系統中的,從程式角度來說,子系統主要提供了遊戲的機制。比如,子系統中的任務系統,提供了可以做任務的遊戲機制,之後在這個子系統上做一些配置,就可以運轉起來,例如《魔獸世界》的任務就非常多。而每個遊戲或多或少都會有一些子系統,每個子系統都固化了遊戲的某一個方面,比如戰鬥、任務、劇情、UI、場景等。

中間層,是通用元件,它不是特別複雜的層次。這一層不涉及設計模式,而且和遊戲的關係不是很大。通用元件是在引擎的支援下,提供遊戲邏輯無關的功能。一般對引擎直接提供的功能的封裝,或使用子系統的擴充套件機制來實現的功能屬於通用元件層。僅僅有子系統和指令碼,是不足以表達整個遊戲的,所以需要通用元件來輔助。

最上層,是膠水層,即指令碼。這個遊戲demo用的不是一個標準的指令碼,是我自己開發的一個DSL指令碼,我們專案用來做劇情指令碼,是一個基於命令佇列的執行模型,它在語法上還是屬於C語言風格,這塊大家有興趣可以看原始碼瞭解,不關心細節可以當作文字描述或配置來看。

《無盡之路》的系統

驅動遊戲世界運轉的“心跳”:遊戲迴圈及實時模擬

《無盡之路》這遊戲個demo比較簡單,只涉及五個子系統:場景與資源、玩家與NPC、AI、玩法邏輯和UI,劇情其實是沒有涉及的,只是用指令碼來實現了玩法。遊戲開始是初始化,之後進入到遊戲迴圈,整個遊戲的邏輯,都是由遊戲迴圈來驅動的。

《無盡之路》的開發方式

驅動遊戲世界運轉的“心跳”:遊戲迴圈及實時模擬

根據遊戲架構的三個層次,遊戲的開發步驟可以分為三步:①確定子系統;②確定通用功能;③使用DSL指令碼實現遊戲邏輯。

首先,對於子系統,要確定遊戲會涉及哪些方面的系統。對於《無盡之路》,它只需要有場景、玩家、NPC和很簡單的AI,以及一個指令碼來實現玩法就可以了。

其次,是通用功能,它跟遊戲邏輯的關係不是直接密切相關的。通用功能,一般會涉及相機、UI、小球的空間管理、重力、特效、音效與背景音樂。相機一般不被當做一個系統,它只是簡單地實現遊戲裡以第三者視角跟隨的功能。UI一般是有系統的,但這個遊戲裡所涉及的UI比較簡單,所以將UI作為一個通用功能,為了在指令碼里操作UI,會提供UI上的簡單封裝。

關於小球的空間管理,一般採用KD樹的方式。當玩家走到一個地方時,遊戲系統就需要知道,玩家周圍大概有多少小球被觸發。如果要完全遍歷,那麼玩家每走一步,系統就要計算兩千多個小球和玩家的2D距離,計算量非常大,這時就會採用Kd樹來進行空間管理。

Demo中的重力、特效和音效,是直接使用Unity引擎的,在demo中只是做了一個指令碼提供介面,供DSL指令碼呼叫。

#03《無盡之路》的功能支撐

UI

驅動遊戲世界運轉的“心跳”:遊戲迴圈及實時模擬

《無盡之路》的UI設計,使用的是Unity的UGUI。在UGUI之前,Unity比較常用的是NGUI,在NGUI的作者設計出UGUI後,UGUI更廣泛地使用。Unity3d底層所提供的主要是畫布和畫布的渲染,這兩者的應用,使得UGUI比NGUI快一些。

3D遊戲裡,所有的UI都是用兩個三角形拼一個矩形,然後在矩形上貼圖,構成玩家看到的UI系統。Mesh指的是,由一系列三角形組成的多邊形網格。3D遊戲都要考慮渲染的批次問題,即從CPU提交到GPU的次數,這個次數越多,遊戲效能就越低。類似於上文提到的多執行緒加鎖,加鎖相當於並行變成序列。同樣的,渲染批次過多,就會導致CPU和GPU之間序列的時間越長,遊戲效能就會很低。為了減少渲染批次、提高效能,UI系統會把小三角形,按照某種方式拼接,構造成一個個的Mesh。

此外,UI系統通常會有輸入系統,一般涉及事件分發與冒泡。事件分發是指,當玩家在UI上點選時,系統需要找到玩家觸發的按鈕。當玩家點選按鈕,但對應的事件並沒有響應時,就需要通過冒泡來逐級上報,直到容器一級的控制元件得到通知。由此可以看出,事件處理不一定要安裝在最底層的按鈕上,也可以放在更上一層,通過冒泡功能的應用,來同時進行多個按鈕的處理。

驅動遊戲世界運轉的“心跳”:遊戲迴圈及實時模擬

UI系統也使用了一個開源庫Tween,將這個指令碼掛在Unity裡的GameObject上,為遊戲提供一些功能,包括位置動畫、顏色動畫和alpha動畫。Tween在這個demo裡,主要用來做傷害數字,即飄字。

通用功能

驅動遊戲世界運轉的“心跳”:遊戲迴圈及實時模擬

在通用功能中相機的實現裡,螢幕輸入的指令碼是用C#寫的。這個指令碼,主要實現的是搖桿和鍵盤的移動,以及相機的一些調整。上圖所示的相機,只是3d遊戲設計中的一種傳統方式。Unity加了timeline之後的相機外掛,有很多虛擬相機,與圖中的方式就不太相同。傳統相機的表達,會有特定的目標,相機會看著或跟隨這個目標。相機的引數調整,主要包括相機的yaw、相機到目標的距離和相機的高度。相機的yaw,指的是繞這個目標旋轉的方向。

驅動遊戲世界運轉的“心跳”:遊戲迴圈及實時模擬

空間管理的實現,是依靠ObjectDetector指令碼的。它使用KdTree查詢玩家周圍的小球,併傳送劇情訊息,即DSL指令碼。這個過程就是根據KdTree的資料來進行處理,與遊戲就沒有關係了。

驅動遊戲世界運轉的“心跳”:遊戲迴圈及實時模擬

StoryObject主要用於處理每個角色頭頂的飄字。此外,它還會處理遊戲中的碰撞回撥。當小球掉落到地面時,會引起爆炸,這時就需要通過C#使用引擎對應的功能,做一層轉接,發訊息到劇情指令碼里,進行檢測。在做遊戲時,像這種將特定處理利用通用機制,轉發到指令碼進行處理的方式,是很常見的。

StoryCamera是在實現上文提到的三個引數的調整之後,具體實現相機功能的指令碼。

#04《無盡之路》的機制與系統支撐

時間

驅動遊戲世界運轉的“心跳”:遊戲迴圈及實時模擬

時間是由TimeUtility類實現的,包括幀時間和高精度時間。

場景子系統

驅動遊戲世界運轉的“心跳”:遊戲迴圈及實時模擬

《無盡之路》的場景檔案,直接採用了Unity3d提供的格式。場景子系統主要負責場景的載入、展示和切換。首先,場景要進行資源的載入,並實現場景的例項化,最後顯示場景上的各種物件。場景的切換,一般包括清理舊的場景,並載入新的場景,最後初始化新的場景。

資源子系統

驅動遊戲世界運轉的“心跳”:遊戲迴圈及實時模擬

遊戲伺服器相對於之前已經發展了許多,而且計算機的綜合能力也越來越強。

資源子系統相對來說複雜一些,主要有資源的組織、打包、載入和資源池及物件池的處理。資源的組織,是對3d資源進行分類,主要會涉及引擎資源和原始資源。引擎資源是引擎可以識別的,存放在Assets目錄下或它的普通子目錄(指名稱不是unity約定的特殊目錄)。原始資源,是開發者要用的,放在Assets/StreamingAssets目錄下。

其次,是資源打包。資源打包在demo中沒有涉及,但實際遊戲的製作中都會用到。比如,美術做的貼圖、特效模型,都是一個一個的資原始檔,到遊戲釋出時,所有的資源會被打包為一個壓縮包。Unity提供了AssetBundle的機制,用於資源的打包。

然後,是資源的載入,它對前端來說是比較關鍵的。資源載入的方式,有同步和非同步兩種。同步載入時,如果資源在讀取時,當前的遊戲畫面類似於卡住了,那麼載入的時間,會直接影響遊戲的幀率。如果時間很長,玩家就會感到明顯的卡頓。為了解決同步載入的卡頓問題,需要考慮兩個策略:第一,是資源的預載入。即在場景切換時,提前把需要的資源載入好,這樣遊戲載入的時間,就不會體現在遊戲過程中。第二,就是資源載入的另一種方式——非同步載入。舉例來講,假設現在有一個NPC進入玩家的視野,那麼就需要建立它。此時,先去發起一個載入資源的請求,等到資源載入完成,進行回撥,再實際去建立NPC的形象。資源的載入在Unity中使用的機制,根據資源是否打包,是有差別的。

最後,是資源池和物件池,二者都是為預載入服務的。資源池用於儲存預載入的資源,物件池用於快取預載入中所需要的遊戲物件。

遊戲物件子系統

驅動遊戲世界運轉的“心跳”:遊戲迴圈及實時模擬

遊戲物件,包括玩家和NPC等。在傳統的遊戲裡,遊戲物件的設計較多使用繼承的方式,但這種方式目前不被推薦,而是更推崇組合的方式。

Unity使用的是ECS模式,它最早是在07年被提出的。遊戲設計早期,物件導向是比較主流的方式,開發者很自然地按照物件導向的思維去考慮,對遊戲物件進行分類,但這會產生一個問題:對遊戲物件進行了分類,同一功能在不同遊戲物件上的實現,可能要分別編寫程式碼,非常麻煩。比如,遊戲裡的NPC和陷阱,都屬於遊戲物件。若按照傳統的方式去分類,往往會將遊戲物件分成靜態遊戲物件和動態遊戲物件,所以陷阱是靜態的,NPC是動態的。靜態物件下,又可以分成各種各樣的型別;動態物件下,可能會分NPC、Boss、Monster等。而當遊戲的後續需求提出需要AI時,將AI與動態遊戲物件進行關聯是比較自然的,但若又想要靜態遊戲物件,比如陷阱也加上AI的效果,因為AI只在動態遊戲物件上才有,這時就需要在靜態遊戲物件這邊新拷貝一份程式碼。這也是在遊戲開發裡比較普遍的問題。

在出現這樣的問題後,一些開發者發現遊戲物件並不是一個物件導向的概念,不太適合按照分類的方式去管理,它更像遊戲物件的資料庫。遊戲物件資料庫的概念被提出後,開始出現了ECS模式和麵向資料的設計。

所以,遊戲物件設計的發展順序是:

首先,是非繼承方式的ECS模式,當時主要考慮的是遊戲物件的管理,即它是OO(Object Oriented,物件導向)風格的分類方式還是資料的組織方式。

其次,在大家逐漸認可資料的組織方式之後,出現了面向資料的設計。而且,在遊戲引擎層面,面向資料的設計可以很方便地做到Cache友好。因為OO的方式是按照Class把資料拆開了,在記憶體中,資料散落在各個不同的區域。而Cache是關聯儲存,只載入自身附近的資料,如果連續訪問遊戲物件,會不停出現Cache資料的替換,Cache基本是無法命中的。所以,現在遊戲物件逐漸傾向於面向資料的設計。

劇情子系統

驅動遊戲世界運轉的“心跳”:遊戲迴圈及實時模擬

劇情子系統是基於DSL語法的,主要是訊息和訊息處理。傳統的指令碼是基於棧的,這個demo用的指令碼,也稱為劇情,它是基於佇列的。基於佇列的執行模型,使得劇情可以方便地實現類似協程的效果。

AI子系統

驅動遊戲世界運轉的“心跳”:遊戲迴圈及實時模擬

《無盡之路》用的AI子系統是比較簡單的狀態機的方式,這在遊戲裡是比較常見的。AI狀態,包括休閒狀態、移動狀態、追擊狀態、戰鬥狀態和脫戰狀態等。在遊戲中,當一個怪物視野範圍內沒有玩家時,它就處於休閒狀態,做出指定動作。當它的視野內出現玩家,它就會進入追擊狀態,不斷地接近玩家,直到玩家進入它的技能範圍,即攻擊範圍內。當玩家進入到攻擊範圍,它就會切換到戰鬥狀態,選擇各種技能進行戰鬥。如果玩家離開,它可能還會再次回到追擊狀態。

每一個怪物是從屬於一個特定區域的,如果它追擊玩家的距離過遠,將要脫離所屬的區域,它就會進入脫戰狀態,回到所屬區域。這種狀態機機制,在MMO(Massive Multiplayer Online,大型多人線上)遊戲中比較常見。

#05 小結

驅動遊戲世界運轉的“心跳”:遊戲迴圈及實時模擬

關於遊戲迴圈的知識,本文講解的還不夠全面,未涉及的部分,有網路與訊息處理、邏輯資料管理、使用者輸入與操作、戰鬥系統和熱更新等。

來源:騰訊遊戲學院

相關文章