剖析虛幻渲染體系(01)- 綜述和基礎

0嚮往0發表於2020-10-26

 

 

1.1 虛幻簡介

虛幻引擎(Unreal Engine,UE)是一款集圖形渲染和開發套件的商業引擎,在歷經數十年的發展和沉澱,於百擎大戰中脫穎而出,成為引領實時渲染領域的全球性的通用商業引擎,廣泛應用於遊戲、設計、模擬、影視、教育、醫學等行業。它出自遊戲公司Epic Games,最初由Tim Sweeney負責,從上世紀90年代中期就開始,已經經歷了20多年,歷經數個大版本迭代。

1.1.1 Unreal Engine 1(1995)

1995年起由Tim Sweeney帶頭研發,到1998年開發出第一款遊戲Unreal,這是一款第三人稱的射擊遊戲,從此開啟了Unreal Engine的通用商業引擎的大門。

作為初代引擎,具備如下特性:

  • 彩色光照(colored lighting)。

  • 有限紋理過濾(a limited form of texture filtering)。

  • 碰撞檢測(collision detection)。

  • 場景編輯器。

  • 軟渲染器(CPU端執行繪製指令,後移到硬體加速的Glide API)。

    Unreal Engine初代編輯器的介面。

1.1.2 Unreal Engine 2(1998)

虛幻2代依然由Tim Sweeney帶頭研發,1998年開始研發,2002年完成第二版本的開發,並研發了對應的多人射擊遊戲America‘s Army等數款遊戲。

相比第一代,第二代虛幻的特性主要體現在:

  • 更加完善的工具鏈。

  • 影院級編輯工具鏈。

  • 粒子系統。

  • 支援DCC骨骼動畫匯出等外掛。

  • 基於C++的wxWidgets工具箱。

  • 基於Karma physics engine的物理模擬:布偶碰撞、剛體碰撞等。

    Unreal Engine 2編輯器介面。

    基於Unreal Engine 2開發的遊戲Killing Floor的畫面。

1.1.3 Unreal Engine 3(2004)

虛幻3經歷一年半的閉門研發,於2004釋出。這個版本也給行業帶來了諸多新特性,主要有:

  • 物件導向的設計。

  • 資料驅動的指令碼。

  • 物理系統。

  • 音效系統。

  • 全新的動態所見即所得的工具鏈。

  • 可程式設計渲染管線。

  • 逐畫素的光影計算。

  • Gamma校正的HDR渲染器。

  • 可破壞的環境。

  • 動態軟體模擬。

  • 群體角色擬。

  • 實時GI解決方案。

    Unreal Engine 3編輯器介面。

虛幻3的遊戲代表作比較多,主要有Gear of War、RobotBlitz、Infinity Blade、Dungeon Defenders、Batman: Arkham City、Aliens: Colonial Marines等等。

Batman: Arkham City的遊戲畫面一覽。

1.1.4 Unreal Engine 4(2008)

Unreal Engine 4早在2008年就釋出了,迄今已經走過了12個年頭。經歷了20多個版本的迭代,引入了無數令人驚豔的特性,包含但不限於:

  • PBR的渲染管線和配套工具鏈。
  • 基於DXR和RTX的實時光線追蹤。
  • 藍圖系統。
  • 視覺化材質編輯器。
  • 延遲渲染管線。
  • 移動平臺輕量化渲染管線。
  • VR渲染管線。
  • Niagara等GPU粒子。
  • 更加真實的物理模擬(破壞、碰撞、軟體等)。
  • 更加完善的遊戲和影視化生產工具鏈。
  • 支援更多主流平臺。
  • ......

UE4 編輯器一覽。

UE4.22實時光追畫面一覽。

隨著UE4的發展和Epic Games公司策略的變更,最終於2015年做了一個震驚行業的決定:對所有使用者免費,並且開放了原始碼。從此,任何人和機構都可以研究UE的原始碼,也由此有了此篇系列文章的誕生。

基於UE4研發的遊戲大作也愈發多起來,代表作有戰爭機器4、黎明死線、絕地求生、和平精英、刀劍神域、我的世界-地下城、最終幻想VII重製版、嗜血程式碼等等。

最終幻想7重製版真實絢麗動感的畫面。

除了遊戲行業,影視、模擬、設計、廣電、科學視覺化等行業也逐步引入了UE作為視覺化生產的利器,並逐漸完善了相對應的工具鏈。

Unreal Engine 4渲染出的影視級虛擬角色。

1.1.5 Unreal Engine 5(2021)

在2020年5月,虛幻官方放出了一個展示虛幻5代渲染特性的視訊“Lumen in the Land of Nanite”,視訊展示了基於虛擬微多邊形幾何體的Nanite和實時全域性光照的Lumen技術,給實時遊戲帶來了影視級的視聽體驗。這哪是遊戲,明明是電影!相信當時很多讀者都被這個視訊刷屏了,也著實讓大家驚豔了一把,筆者第一次看到這個視訊時,激動興奮不已,反覆看了好多遍。

“Lumen in the Land of Nanite”演示視訊的一幀畫面。

據官方介紹,Nanite支援同屏千億級的多邊形數量,意味著不再需要模型拓撲、法線貼圖等傳統美術製作工序,直接採納高模渲染。而Lumen是一套全動態全域性光照解決方案,能夠對場景和光照變化做出實時反應,且無需專門的光線追蹤硬體。該系統能在巨集大而精細的場景中渲染間接鏡面反射和可以無限反彈的漫反射。

除此之外,該視訊還展示了Chaos物理與破壞系統、Niagara VFX、卷積混響和環境立體聲渲染等新功能特性。

至於UE5的釋出時間,直接引用官方說明直截了當:

虛幻引擎4與5的上線時間表

虛幻引擎4.25已經支援了索尼和微軟的次世代主機平臺。目前Epic正與主機制造商、多家遊戲開發商及發行商密切合作,使用虛幻引擎4開發次世代遊戲。

虛幻引擎5將在2021年早些時候釋出預覽版,並在2021年晚些時候釋出完整版。它將支援次世代主機、現世代主機、PC、Mac、iOS和Android平臺。

我們正在設計向前相容的功能,以便大家先在UE4中開始次世代開發,並在恰當的時機將專案遷移到UE5。

我們將在次世代主機發售後,讓使用UE4開發的《堡壘之夜》登陸次世代主機。為了通過內部開發證明我們行業領先的技術,我們將在2021年中將該遊戲遷移至UE5。

也就是說,UE官方如果不放鴿子的話,將在2021年釋出UE5的完整版。讓我們拭目以待吧。

 

1.2 渲染綜述

1.2.1 虛幻渲染衍變

縱觀UE的發展史,UE其實也是順應硬體技術和軟體技術發展的趨勢,善於結合軟硬體的新特性,加上軟體工程學、作業系統等等技術封裝而成的結果。比如90年代中期隨著硬體的發展,增加了16位真彩色的渲染管線,98年增加到了32位RGBA;本世紀之初,基於硬體的可程式設計渲染API湧現後,UE緊接著同時支援固定管線(現已廢棄)和可程式設計渲染管線。隨後若干年,HDR湧現,延遲渲染管線的引入,曲面細分、Compute Shader等都遵循著這樣的規律。

圖中展示的是DirectX 11新加入曲面細分、計算著色器等新特性。

一直到前些年,CPU的摩爾定律到達天花板,CPU廠商只能調轉策略大力發展多核心,由此,CPU多核心和GPU資料驅動並行化得到強力發展,以及Vulkan、DirectX12、Metal等輕量化、多執行緒友好的圖形API的出現,UE加入了複雜的多執行緒渲染,以便充分發揮現代CPU多核心和GPU海量計算單元的效能優勢。

CPU的核心頻率增長從1970年到2011年一直保持著摩爾定律,但隨著晶片工藝發展的滯漲,之後就明顯跟不上摩爾定律曲線。(圖右是Intel創始人摩爾本人。)

2006年前後CPU的效能明顯落後於摩爾定律曲線,但同時,CPU的核心數量也隨之增加。

由於UE要支援眾多的主流作業系統,封裝眾多圖形API及對應的Shader,所以UE的渲染體系需要層層封裝原始API,將原本簡單的API演變成如今錯綜複雜的UE體系。比如,為了跨多種圖形API,加入了RHI體系,解決使用者層裸呼叫圖形API的問題;為了方便使用者使用和編輯材質效果,引入材質模板和材質編輯器,並且底層使用一系列中間層將shader編譯到對應的硬體平臺;為了充分發揮多核優勢,引入了遊戲執行緒、渲染執行緒、RHI執行緒,為了解決執行緒訪問衝突和競爭,引入了以U開頭的遊戲執行緒代表,同時有與之對應的以F開頭的渲染執行緒代表;為了模型合批、減少DrawCall等渲染優化,增加了動態和靜態渲染路徑,增加FMeshPassProcessor、FMeshBatch、FMeshDrawCommand等概念;為了適應和充分利用Vulkan、DirectX12等這種新型輕量級現代圖形API,UE還在4.22引入了RDG(渲染依賴圖表);諸如此類,枚不勝數。

Frame Graph(或RDG)將引擎功能模組和GPU資源相分離,結構更加清晰,可以針對性對記憶體、視訊記憶體、Pass等執行排程優化。

再到前幾年,AI技術隨勢崛起,並被行業研發人員引入到圖形學領域,充分發揮在實時降噪領域。加上NVIDIA的Turing硬體架構對Tensor Core和Raytrace Core的整合,以及微軟在DirectX Raytracing對光追的標準API的整合,實時領域的光線追蹤終於迎來了春天,得到了蓬勃發展。通用商業引擎率先整合實時光追的就是UE 4.22,並且放出了對應的演示視訊《Troll》

Epic Games在釋出UE 4.22時,宣佈支援實時光線追蹤,並聯合Goodbye Kansas Studios釋出了演示視訊《Troll》。

綜合起來,UE渲染體系呈現如今複雜局面的主要原因有:

  • 順應軟體和硬體技術的發展。
  • 迎合物件導向的軟體工程學的思想和設計。
  • 架構模組化,提高複用性、可擴充套件性,降低耦合。
  • 跨平臺,跨編譯器,跨圖形API。
  • 相容舊有的功能、程式碼、介面。
  • 提升渲染效率,提升效能比,提高魯棒性。
  • 封裝API底層細節,抽離渲染系統的細節和複雜性,以便減輕GamePlay層使用者(邏輯程式設計師、美術、TA、策劃等)的學習和使用成本。
  • 為了提升引擎通用性,不得不加入多層次多重概念的封裝。

縱觀整個圖形渲染行業的發展,行業研發人員的目標都是一致的,那就是:充分利用有限的硬體資源,更快更好地渲染出更具真實或更具風格化的畫面。

1.2.2 內容範圍

目前已有很多人寫過剖析虛幻渲染的書(如《大象無形 虛幻引擎程式設計淺析》)或技術文章(如Unreal Engine 4 Rendering系列《房燕良-虛幻4渲染系統架構解析》以及眾多的知乎文章),但是筆者認為他們的文章只能是揭示UE渲染體系的一部分,至少目前還沒發現一本書或一個系列文章能夠較完整地剖析UE渲染體系的全貌。鑑於此,筆者斗膽擔任這個重任,但畢竟精力有限,技術也有限,若有錯漏,懇請讀者們指正。

本系列文章集中精力和筆墨剖析UE的渲染體系,更具體地講,主要限定在以下UE目錄的原始碼:

  • Engine\Source\Runtime\RendererCore。
  • Engine\Source\Runtime\Renderer。
  • Engine\Source\Runtime\RHI。
  • 部分RHI模組:D3D12RHI,OpenGLDrv,VulkanRHI等。
  • 部分基礎模組:Core,CoreUObject等。

當然,如果有需要也會涉及以上並未出現的程式碼檔案,但之後不會特意提出。

 

1.3 基礎模組

本節主要簡述渲染系統常用到的一些基礎知識、概念和體系,以便對於不熟悉或基礎較薄弱的讀者有個過渡和切入點。如果是UE老手,可以跳過本節內容。

1.3.1 C++新特性

本小節簡述一下UE和渲染系統中常涉及到的C++新特性(C++11,C++14及之後的版本)。

1.3.1.1 Lambda

C++的lambda是C++11才有的特性,跟C#和Lua等指令碼語言的閉包和匿名函式如出一轍,不過使用上更加複雜、多樣性,更加貼近Native語言獨特的風格。它的語法形式有幾種:

(1)	[ captures ] <tparams>(optional)(C++20) ( params ) specifiers exception attr -> ret requires(optional)(C++20) { body }
(2)	[ captures ] ( params ) -> ret { body }
(3)	[ captures ] ( params ) { body }
(4)	[ captures ] { body }

其中第(1)種是C++20才支援的語法,UE暫時沒有用到,其它三種是常見的形式。用得最多的是給渲染執行緒壓入渲染命令,如FScene::AddPrimitive((......)表示省略了部分程式碼,下同):

// Engine\Source\Runtime\Renderer\Private\RendererScene.cpp

void FScene::AddPrimitive(UPrimitiveComponent* Primitive)
{
	(......)
    
	FScene* Scene = this;

	TOptional<FTransform> PreviousTransform = FMotionVectorSimulation::Get().GetPreviousTransform(Primitive);

	ENQUEUE_RENDER_COMMAND(AddPrimitiveCommand)(
		[Params = MoveTemp(Params), Scene, PrimitiveSceneInfo, PreviousTransform = MoveTemp(PreviousTransform)](FRHICommandListImmediate& RHICmdList)
		{
			FPrimitiveSceneProxy* SceneProxy = Params.PrimitiveSceneProxy;
			FScopeCycleCounter Context(SceneProxy->GetStatId());
			SceneProxy->SetTransform(Params.RenderMatrix, Params.WorldBounds, Params.LocalBounds, Params.AttachmentRootPosition);

			// Create any RenderThreadResources required.
			SceneProxy->CreateRenderThreadResources();

			Scene->AddPrimitiveSceneInfo_RenderThread(PrimitiveSceneInfo, PreviousTransform);
		});
}

在生成閉包時,存在多種方式捕獲當前環境(作用域)的變數:按值(直接將變數放入captures列表)或按引用(變數前加&符號,並放入captures列表)。上述FScene::AddPrimitive用的就是按值的方式傳遞Lambda的變數。由於傳進Lambda的變數生命週期是由編碼人員保證的,所以UE大多使用的是按值傳遞的方式,防止訪問無效記憶體。

更多詳細說明請參閱C++官方網站關於Lambda的說明:Lambda expressions

1.3.1.2 Smart Pointer

自C++11起,標準庫就引入了一套智慧指標(Smart Pointer)唯一指標(unique_ptr)共享指標(shared_ptr)弱指標(weak_ptr),旨在減輕編碼人員在記憶體分配和追蹤方面的負擔。

不過虛幻並沒有直接使用這套指標,而是自己實現了一套。除了上面提到的三種,UE還可新增共享引用,此類引用的行為與不可為空的共享指標相同。虛幻Objects使用更適合遊戲程式碼的單獨記憶體追蹤系統,因此這些類無法與UObject系統同時使用。

它們的對比和說明如下表:

名稱 UE C++ 說明
共享指標 TSharedPtr shared_ptr 共享指標擁有其引用的物件,無限防止該物件被刪除,並在無共享指標或共享引用引用其時,最終處理其的刪除。共享指標可為空白,意味其不引用任何物件。任何非空共享指標都可對其引用的物件生成共享引用。
唯一指標 TUniquePtr unique_ptr 唯一指標僅會顯式擁有其引用的物件。僅有一個唯一指標指向給定資源,因此唯一指標可轉移所有權,但無法共享。複製唯一指標的任何嘗試都將導致編譯錯誤。唯一指標超出範圍時,其將自動刪除其所引用的物件。
弱指標 TWeakPtr weak_ptr 弱指標類與共享指標類似,但不擁有其引用的物件,因此不影響其生命週期。此屬性中斷引用迴圈,因此十分有用,但也意味弱指標可在無預警的情況下隨時變為空。因此,弱指標可生成指向其引用物件的共享指標,確保程式設計師能對該物件進行安全臨時訪問。
共享引用 TSharedRef - 共享引用的行為與共享指標類似,即其擁有自身引用的物件。對於空物件而言,其存在不同;共享引用須固定引用非空物件。共享指標無此類限制,因此共享引用可固定轉換為共享指標,且該共享指標固定引用有效物件。要確認引用的物件是非空,或者要表明共享物件所有權時,請使用共享引用。

UE也提供瞭如同C++類似的工具介面,以更好更快捷地構建智慧指標:

名稱 UE C++ 說明
從this構造共享指標 TSharedFromThis enable_shared_from_this 在新增 AsSharedSharedThis 函式的 TSharedFromThis 中衍生類。利用此類函式可獲取物件的 TSharedRef
構造共享指標 MakeShared, MakeShareable make_shared 在常規C++指標中建立共享指標。MakeShared 會在單個記憶體塊中分配新的物件例項和引用控制器,但要求物件提交公共建構函式。MakeShareable 的效率較低,但即使物件的建構函式為私有,其仍可執行。利用此操作可擁有非自己建立的物件,並在刪除物件時支援自定義行為。
靜態轉換 StaticCastSharedRef, StaticCastSharedPtr - 靜態投射效用函式,通常用於向下投射到衍生型別。
固定轉換 ConstCastSharedRef, ConstCastSharedPtr - const 智慧引用或智慧指標分別轉換為 mutable 智慧引用或智慧指標。

UE自帶的智慧指標庫除了提供記憶體管理訪問、引用計數追蹤等基礎功能,在效率和記憶體佔用上,也可匹敵C++標準版的智慧指標。此外,還提供了執行緒安全的訪問模式:

  • TSharedPtr<T, ESPMode::ThreadSafe>
  • TSharedRef<T, ESPMode::ThreadSafe>
  • TWeakPtr<T, ESPMode::ThreadSafe>
  • TSharedFromThis<T, ESPMode::ThreadSafe>

但是,由於執行緒安全版依賴原子引用計數,效能上比非執行緒安全版本稍慢,但其行為與常規C++指標一致:

  • Read和Copy可保證為執行緒安全。
  • Write和Reset必須同步後才安全。

這些執行緒安全的智慧指標在UE多執行緒渲染的架構下,被應用得比較普遍。

1.3.1.3 Delegate

委託(Delegate)本質上就是函式的型別和代表,方便宣告、引用和執行指定的成員函式。C++標準庫並沒有實現委託,但可以通過晦澀難懂的語法達到類委託的效果。

微軟的內建庫實現了delegate的功能,同樣地,由於UE存在大量委託的需求和應用,所以UE在內部也實現了一套委託機制。UE的委託有三種型別:

  • 單點委託
  • 組播委託
    • 事件
  • 動態物體
    • UObject
    • Serializable

它是通過一組巨集達到宣告的,常見的宣告形式和對應函式定義如下表:

宣告巨集 函式定義或說明
DECLARE_DELEGATE(DelegateName) void Function()
DECLARE_DELEGATE_OneParam(DelegateName, Param1Type) void Function(Param1)
DECLARE_DELEGATE_Params(DelegateName, Param1Type, Param2Type, ...) void Function(Param1, Param2, ...)
DECLARE_DELEGATE_RetVal(RetValType, DelegateName) Function()
DECLARE_DELEGATE_RetVal_OneParam(RetValType, DelegateName, Param1Type) Function(Param1)
DECLARE_DELEGATE_RetVal_Params(RetValType, DelegateName, Param1Type, Param2Type, ...) Function(Param1, Param2, ...)
DECLARE_MULTICAST_DELEGATE(_XXX) 建立一個多播委託型別(可帶引數)
DECLARE_DYNAMIC_MULTICAST_DELEGATE() 建立一個動態多播委託型別(可帶引數)

宣告之後,便可以通過BindXXX和UnBind介面相應地繫結和解綁已有的介面,對於存在繫結的委託,就可以呼叫Execute執行之。使用示例:

// 宣告委託型別
DECLARE_DELEGATE_OneParam(FOnEndCaptureDelegate, FRHICommandListImmediate*);

// 定義委託物件
static FOnEndCaptureDelegate GEndDelegates;

// 註冊委託
void RegisterCallbacks(FOnBeginCaptureDelegate InBeginDelegate, FOnEndCaptureDelegate InEndDelegate)
{
    GEndDelegates = InEndDelegate;
}

// 執行委託(存在繫結的話)
void EndCapture(FRHICommandListImmediate* RHICommandList)
{
    if (GEndDelegates.IsBound())
    {
        GEndDelegates.Execute(RHICommandList);
    }
}

// 解繫結
void UnregisterCallbacks()
{
    GEndDelegates.Unbind();
}

UE的委託實現程式碼在TBaseDelegate:

// Engine\Source\Runtime\Core\Public\Delegates\DelegateSignatureImpl.inl

template <typename WrappedRetValType, typename... ParamTypes>
class TBaseDelegate : public FDelegateBase
{
public:
	/** Type definition for return value type. */
	typedef typename TUnwrapType<WrappedRetValType>::Type RetValType;
	typedef RetValType TFuncType(ParamTypes...);

	/** Type definition for the shared interface of delegate instance types compatible with this delegate class. */
	typedef IBaseDelegateInstance<TFuncType> TDelegateInstanceInterface;

 	(......)   
}

實現的原始碼比較多,使用了模板、多繼承,但本質上也是封裝了物件、函式指標等。

1.3.1.4 Coding Standard

本小節簡述UE官方的建議或強制的常用編碼規範及常識。

  • 命名規則

    • 命名(如型別或變數)中的每個單詞需大寫首字母,單詞間通常無下劃線。例如:HealthUPrimitiveComponent,而非 lastMouseCoordinatesdelta_coordinates

    • 型別名字首需使用額外的大寫字母,用於區分其和變數命名。例如:FSkin 為型別名,而 Skin 則是 FSkin 的例項。

      • 模板類的字首為T。

      • 繼承自 UObject 的類字首為U。

      • 繼承自 AActor 的類字首為A。

      • 繼承自 SWidget 的類字首為S。

      • 介面類(Interface)的字首為I。

      • 列舉的字首為E。

      • 布林變數必須以b為字首(例如 bPendingDestructionbHasFadedIn)。

      • 其他多數類均以F為字首,而部分子系統則以其他字母為字首。

      • Typedefs應以任何與其型別相符的字母為字首:若為結構體的Typedefs,則使用F;若為 Uobject 的Typedefs,則使用U,以此類推。

        • 特別模板例項化的Typedef不再是模板,並應加上相應字首,例如:

          typedef TArray<FMytype> FArrayOfMyTypes;
          
      • C#中省略字首。

      • 多數情況下,UnrealHeaderTool需要正確的字首,因此新增字首至關重要。

    • 型別和變數的命名為名詞。

    • 方法名是動詞,以描述方法的效果或未被方法影響的返回值。

    • 變數、方法和類的命名應清楚、明瞭且進行描述。命名的範圍越大,一個良好的描述性命名就越重要。避免過度縮寫。

    • 所有返回布林的函式應發起true/false的詢問,如IsVisible()ShouldClearBuffer()

    • 程式(無返回值的函式)應在Object後使用強變化動詞。一個例外是若方法的Object是其所在的Object;此時需以上下文來理解Object。避免以"Handle"和"Process"為開頭;此類動詞會引起歧義。

  • STL白名單

    雖然UE因為記憶體管理、效率等方面的原因避免使用部分STL庫並對其實現了一套自己的程式碼,但由於C++標準愈發強大,新加入很多跨平臺的有效模組,所以官方對以下模組保持了白名單狀態(允許使用,且以後不會改變):

    • atomic
    • type_traits
    • initializer_list
    • regex
    • limits
    • cmath
  • 類的宣告應站在使用者角度上,而非實現者,因此通常先宣告類的共有介面和(或)成員變數,再宣告私有的。

    UCLASS()
    class MyClass
    {    
    public:
        UFUNCTION()
        void SetName(const FString& InName);
        UFUNCTION()
        FString GetName() const;
        
    private:
        void ProcessName_Internal(const FString& InName);
    
    private:
        UPROPERTY()
        FString Name;
    };
    
  • 儘量使用const。包含引數、變數、常量、函式定義及返回值等等。

    void MyFunction(const TArray<Int32>& InArray, FThing& OutResult)
    {
        // 此處不會修改InArray,但可能會修改OutResult
    }
    
    void MyClass::MyFunction() const
    {
        // 此程式碼不會改變MyClass的任何成員,則可以在宣告後面新增const
    }
    
    TArray<FString> StringArray;
    for (const FString& :StringArray)
    {
        // 此迴圈的主體不會修改StringArray
    }
    
  • 程式碼應用有清晰且準確的註釋。特定的註釋格式可提供自動文件系統生成編輯器的Tooltips。

    在C++元件給變數新增註釋後,其描述會被UE編譯系統捕獲,從而應用到編輯器的提示中。

  • C++新型語法

    • nullptr代替舊有的NULL。

    • static_assert(靜態斷言)

    • override & final

    • 儘量避免使用auto關鍵字。

    • 新的遍歷語法

      TMap<FString, int32> MyMap;
      
      // Old style
      for (auto It = MyMap.CreateIterator(); It; ++It)
      {
          UE_LOG(LogCategory, Log, TEXT("Key: %s, Value: %d"), It.Key(), *It.Value());
      }
      
      // New style
      for (TPair<FString, int32>& Kvp : MyMap)
      {
          UE_LOG(LogCategory, Log, TEXT("Key: %s, Value: %d"), *Kvp.Key, Kvp.Value);
      }
      
    • 新型的列舉

      // Old enum
      UENUM()
      namespace EThing
      {
          enum Type
          {
              Thing1,
              Thing2
          };
      }
      
      // New enum
      UENUM()
      enum class EThing : uint8
      {
          Thing1,
          Thing2
      }
      
    • 移動語義。所有UE內建容器都支援移動語義,且用MoveTemp代替C++的std::move

    • 類的成員變數初始值。

      UCLASS()
      class UMyClass : public UObject
      {
          GENERATED_BODY()
      
      public:
      
          UPROPERTY()
          float Width = 11.5f;
      
          UPROPERTY()
          FString Name = TEXT("Earl Grey");
      };
      
  • 第三方庫特定格式。

    // @third party code - BEGIN PhysX
    #include <physx.h>
    // @third party code - END PhysX
    
    // @third party code - BEGIN MSDN SetThreadName
    // [http://msdn.microsoft.com/en-us/library/xcb2z8hs.aspx]
    // Used to set the thread name in the debugger
    ...
    //@third party code - END MSDN SetThreadName
    

更完整的編碼規範請參閱UE官方文件:Coding Standard

1.3.2 容器

虛幻引擎自身實現了一套基礎容器和演算法庫,並沒有使用STL的標準庫。但是,它們部分可以和STL找到一一對應的關係,見下表:

容器名稱 UE4 STL 解析
陣列 TArray vector 連續陣列,可增加、刪除、排序元素,功能比stl的vector更強大方便。新增陣列時會按需重新分配記憶體,陣列的長度按照一定策略增長(增長策略詳見後面)。
元組 TTuple tuple 儲存一組資料,構建後不可改變其長度,元素型別可不一樣。
連結串列 TList forward_list 單向連結串列,操作和底層實現類同stl。
雙向連結串列 TDoubleLinkedList list 雙向連結串列,操作和底層實現類同stl。
對映表 TMap map 鍵-值一一對映表,有序,底層用TSet實現,並且儲存了一組鍵值配對陣列。
多值對映表 TMultiMap unordered_map 鍵-多值的對映表,有序,底層實現基本同TMap,但增加元素時不會刪除已有的值。不同的是,stl的unordered_map是無序的,使用雜湊表進行儲存和索引。
有序對映表 TSortedMap map 鍵-值一一對映表,有序,底層用按鍵排好序的TArray實現,並且儲存了一組鍵值配對陣列。佔用的記憶體比TMap少一半,但增刪元素複雜度是O(n),查詢複雜度是O(Log n)。
集合 TSet set 鍵的集合,且鍵不能重合。底層使用TSparseArray實現,並且元素儲存於桶(bucket),桶的數量隨元素大小而定,且用Hash值連結儲存的元素。
雜湊表 FHashTable hash_map 常用於索引其它陣列。根據其它Hash函式獲取指定ID的Hash值,然後儲存、查詢其它陣列的元素。
佇列 TQueue queue 無邊界非侵入式佇列,使用無鎖(lock-free)連結串列實現。支援多生產者-單消費者(MPSC)和單生產者-單消費者(SPSC)兩種模式,兩種模式下都是執行緒安全的。常用於多執行緒之間的資料傳輸和訪問。
迴圈佇列 TCircularQueue - 無鎖迴圈佇列,先進先出,使用迴圈陣列(TCircularBuffer)實現,在單生產者-單消費者(SPSC)模式下執行緒安全。
迴圈陣列 TCircularBuffer - 底層使用TArray實現,無邊界,建立時需要指定容量大小,後面無法再更改容量大小。
字串 FString string 可動態改變內容和大小的字串,與stl的string類似,但功能更齊備。底層採用TArray實現。另外,它還有優化版本FText、FName。

以上UE和STL的對應關係是僅從提供的呼叫介面(使用者)的角度來考量,但實際底層的實現機制可能存在很大的差異,特別說明這一點。例如,細細分析一下TArray的元素尺寸增長策略(對部分巨集和分支做了簡化):

// Array.h

void TArray::ResizeGrow(SizeType OldNum)
{
	ArrayMax = AllocatorInstance.CalculateSlackGrow(ArrayNum, ArrayMax, sizeof(ElementType));
	AllocatorInstance.ResizeAllocation(OldNum, ArrayMax, sizeof(ElementType));
}


// ContainerAllocationPolicies.h

SizeType CalculateSlackGrow(SizeType NumElements, SizeType NumAllocatedElements, SIZE_T NumBytesPerElement) const
{
	return DefaultCalculateSlackGrow(NumElements, NumAllocatedElements, NumBytesPerElement, true, Alignment);
}

template <typename SizeType>
SizeType DefaultCalculateSlackGrow(SizeType NumElements, SizeType NumAllocatedElements, SIZE_T BytesPerElement, bool bAllowQuantize, uint32 Alignment = DEFAULT_ALIGNMENT)
{
	const SIZE_T FirstGrow = 4;
	const SIZE_T ConstantGrow = 16;

	SizeType Retval;
	checkSlow(NumElements > NumAllocatedElements && NumElements > 0);

	SIZE_T Grow = FirstGrow; // this is the amount for the first alloc

	if (NumAllocatedElements || SIZE_T(NumElements) > Grow)
	{
		// Allocate slack for the array proportional to its size.
		Grow = SIZE_T(NumElements) + 3 * SIZE_T(NumElements) / 8 + ConstantGrow;
	}

	if (bAllowQuantize)
	{
		Retval = (SizeType)(FMemory::QuantizeSize(Grow * BytesPerElement, Alignment) / BytesPerElement);
	}
	else
	{
		Retval = (SizeType)Grow;
	}
	// NumElements and MaxElements are stored in 32 bit signed integers so we must be careful not to overflow here.
	if (NumElements > Retval)
	{
		Retval = TNumericLimits<SizeType>::Max();
	}

	return Retval;
}

從上面可以看出TArray記憶體長度的增長策略:第一次分配時,會增長至少4個元素大小;後面會根據新的元素大小按比例增長且固定增長16。隨後會調整成8的倍數和記憶體對齊。可見,它的記憶體增長策略和STL的vector有較大的差別。

以上只是列出常用的一小部分UE容器,UE的容器數量有數十個,完整的列表在Containers

1.3.3 數學庫

虛幻引擎實現了一套數學庫,程式碼在Engine\Source\Runtime\Core\Public\Math目錄下。下面將列出常用的型別和解析:

型別 名稱 解析
FBox 包圍盒 軸平行的三維包圍盒,常用於包圍體、碰撞體、可見性判定等。
FBoxSphereBounds 球-立方體包圍盒 內含一個球體和一個軸平行的立方體包圍盒資料,它們各用於不同的用途,如球體用於場景遍歷加速結構,而立方體用於碰撞檢測等。是大多數可見物體的包圍盒的型別。
FColor Gamma空間顏色 儲存RGBA8888 4個通道的顏色值,它們處於Gamma空間,可由線性空間的FLinearColor轉換而來。
FLinearColor 線性空間顏色 儲存RGBA4個通道的顏色值,每個通道精度是32位浮點值,它們處於線性空間,可由Gamma空間的FColor轉換而來。
FCapsuleShape 膠囊體 儲存了兩個圓和一個圓柱體的資料,兩個圓位於圓柱體兩端,從而組合成膠囊體。常用於物理碰撞膠囊體。
FInterpCurve 插值曲線 模板類,儲存了一系列關鍵幀,提供插值、導數等介面,方便外部操作曲線。
FMatrix 4x4矩陣 包含著16個浮點值,用於儲存空間的變換,如旋轉、縮放、平移等剛體變換和切變等非剛體變換。
FMatrix2x2 2x2矩陣 包含2x2的矩陣,用於2D空間的變換。
FQuat 四元數 儲存了四元數的4維資料,關聯著旋轉軸和旋轉角。常用於旋轉及旋轉插值等操作。
FPlane 平面 用一個點和額外的W值描述的三維空間的平面。
FRay 射線 用一個點和一個向量描述三維空間的射線。
FRotationMatrix 旋轉矩陣 沒有平移的旋轉矩陣,繼承自帶平移的旋轉矩陣FRotationTranslationMatrix。
FRotator 旋轉器 提供Pitch、Yaw、Roll描述的旋轉結構,更加符合人類視角的旋轉描述方式,方便邏輯層操控物體(如相機)的旋轉。
FSphere 球體 用一個點和半徑來描述的三維空間球體。
FMath 數學工具箱 跨平臺、精度相容的數學常量定義和工具函式合集。
FVector 3D向量 三維空間的向量,每個維度為浮點值,也可用作描述點。
FVector2D 2D向量 二維的向量,也可描述2D點。
FVector4 4D向量 儲存著XYZW四個維度的向量,可用於齊次座標、投影變換等。

除了上述列出的常用型別外,UE數學庫還提供了不同精度的浮點數、隨機數、邊界、低差異序列、場景管理節點、基於基本型別衍生的輔助類和工具箱等等模組。完整的數學庫列表參見UE原始碼或官方文件:Unreal Engine Math

值得一提的是,UE提供了數個向量SIMD指令優化版本,可定義不同的巨集啟用對應版本:

// Engine\Source\Runtime\Core\Public\Math\VectorRegister.h

// Platform specific vector intrinsics include.
#if WITH_DIRECTXMATH
	#define SIMD_ALIGNMENT (16)
	#include "Math/UnrealMathDirectX.h"
#elif PLATFORM_ENABLE_VECTORINTRINSICS
	#define SIMD_ALIGNMENT (16)
	#include "Math/UnrealMathSSE.h"
#elif PLATFORM_ENABLE_VECTORINTRINSICS_NEON
	#define SIMD_ALIGNMENT (16)
	#include "Math/UnrealMathNeon.h"
#else
	#define SIMD_ALIGNMENT (4)
	#include "Math/UnrealMathFPU.h"
#endif

由上面的程式碼可知,UE支援DirectX內建庫、Arm Neon指令、SSE指令、FPU等版本。

Neon由Arm公司設計而成,是一套單指令多資料(SIMD)的架構擴充套件技術,適用於Arm Cortex-A和Cortex-R系列處理器。

SSE(Stream SIMD Extensions)由Intel設計而成,最先在其計算機晶片Pentium3中引入的指令集,是繼MMX的擴充指令集,適用於x86和x64架構架構。目前已經存在SSE2、SSE3、SSSE3、SSE4等指令集。

FPU(Floating-point unit)是浮點數計算單元,組成CPU核心的一部分硬體結構,是CPU處理浮點數和向量運算的核心單元。

1.3.4 座標空間

UE使用左手座標系(跟DirectX一樣,但OpenGL使用右手座標系),預設關卡(新建的場景)檢視下,Z軸向上,Y朝左,X朝視線後方;但是拖入一個CameraActor到場景,攝像機的預設檢視是Z軸向上,Y朝右,X朝檢視前方。UE座標系的預設檢視跟其它很多引擎都不一樣,剛接觸可能會有點不習慣,不過用久了也不會感到阻礙。

UE的攝像機檢視下預設座標系的朝向如圖所示。

UE的座標空間跟3D渲染管線的轉換基本一致,但也有一些獨有的概念,詳情如下表:

UE座標空間 中文名稱 別名 解析
Tangent 切線空間 - 正交的(插值後會產生偏倚),可能是左手或右手系。TangentToLocal只包含旋轉,不包含位置平移資訊,因此是OrthoNormal(轉置矩陣也是逆矩陣)。
Local 區域性空間 ObjectSpace(物體空間) 正交,可以是左右或右手系(意味著跟三角形裁剪相關,需調整),LocalToWorld包含旋轉、縮放、平移等資訊。縮放可能是負的,用於動畫、風向等模擬。
World 世界空間 - WorldToView矩陣僅包含旋轉、平移,不包含縮放。
TranslatedWorld 帶平移的世界空間 - TranslatedWorld=World+PreViewTranslation,PreViewTranslation就是Camera位置的反向位置,TranslatedWorld相當於是不包含攝像機平移資訊的World矩陣。它廣泛地被用於BasePass、骨骼蒙皮、粒子特效、毛髮、降噪等計算。
View 檢視空間 CameraSpace(攝像機空間) 檢視空間是一個以攝像機近裁剪面中心為原點的座標空間。ViewToClip矩陣包含x,y縮放,但不包含平移。也可縮放和平移深度值z,通常還會應用投影矩陣變換到齊次投影空間。
Clip 裁剪空間 HomogeniousCoordinates(齊次座標), PostProjectionSpace(後投影空間), ProjectionSpace(投影空間) 透視投影矩陣應用後,便可轉換到齊次裁剪空間。需注意的是裁剪空間的W等同於檢視空間的Z。
Screen 螢幕空間 NormalizedDeviceCoordinates(規範化裝置座標) Clip空間的座標應用透視除法後(xyz除以w分量),可獲得螢幕空間的座標。其中螢幕空間的橫向座標從左到右取值[-1, 1],豎向座標從下到上取值[-1, 1],深度從近到遠取值[0, 1](但OpenGL RHI的深度取值[-1, 1])。
Viewport 視口空間 ViewportCoordinates(視口座標), WindowCoordinates(視窗座標) 將螢幕座標對映到視窗的畫素座標。橫向座標從左到右取值[0, width-1],豎向座標從上到下取值[0, height-1](注意螢幕空間的豎向座標從下到上遞增)。

在UE的C++介面或Shader變數中,廣泛存在從一個空間到另外一個空間的變換,它們的名稱是X To Y(X和Y都是上述表格中的空間名詞),常見的如:

  • LocalToWorld
  • LocalToView
  • TangentToWorld
  • TangentToView
  • WorldToScreen
  • WorldToLocal
  • WorldToTangent
  • ......

切線空間不同於區域性空間(模型空間),以每個頂點(或畫素)的法線和切線為軸,從而構造出正交的座標空間。

模型頂點上的切線空間示意圖,每個頂點都有自己的切線空間。

從頂點構造一個正交的切線空間的3條軸(切線T、副切線B、法線N)的常用公式。

為什麼已經有了區域性空間,還需要切線空間呢?

可以從切線空間的作用回答,總結起來主要有以下幾點:

  • 支援各類動畫。包含蒙皮骨骼動畫、程式化動畫、頂點動畫、UV動畫等,由於模型執行動畫運算後,它的法線會產生變化,如果沒有在切線空間實時去校正法線,將會產生錯誤的光照結果。

  • 支援切線空間計算光照。只需要將光源方向L和視線V轉換到切線空間,加上直接從法線取樣獲得的法線N,就可執行的光照計算,獲得正確的光照結果。

  • 可以複用法線貼圖。切線空間的法線貼圖記錄的是相對法線資訊,這意味著,即便把該法線貼圖應用到另外一個完全不同的網格模型上,也可以得到一個相對合理的光照結果。同一個模型可以多次複用法線貼圖,不同的模型也可以複用同一張法線貼圖。例如一個立方體模型,只需要使用一張貼圖就可以用到所有的六個面上。

  • 可壓縮。由於切線空間的法線貼圖的法線的Z方向總是朝向Z軸正方向的,因此法線貼圖只需要儲存XY方向,便可推導得到Z方向。

上面提到法線紋理的壓縮,順帶也說說廣泛存在於UE Shader層的單位向量的壓縮,它們的原理是比較相似的。

Zina H. Cigolle等人早在2014年就發表了論文Survey of Efficient Representations for Independent Unit Vectors,論文中提出了一種將三維的單位向量壓縮成二維的方法。壓縮過程是先將單位球體(Sphere)對映成八面體(Octahedron),之後再投影到二維的立方形(Square),見下圖:

解壓縮的過程就正好相反,UE的shader程式碼清晰地記錄了壓縮和解壓的具體過程:

// Engine\Shaders\Private\DeferredShadingCommon.ush

// 壓縮: 從3維的單位向量轉換到八面體後, 返回2維的結果.
float2 UnitVectorToOctahedron( float3 N )
{
	N.xy /= dot( 1, abs(N) );	// 將單位球體轉換為八面體
	if( N.z <= 0 )
	{
		N.xy = ( 1 - abs(N.yx) ) * ( N.xy >= 0 ? float2(1,1) : float2(-1,-1) );
	}
	return N.xy;
}

// 解壓: 從2維的八面體向量轉換到3維的單位向量.
float3 OctahedronToUnitVector( float2 Oct )
{
	float3 N = float3( Oct, 1 - dot( 1, abs(Oct) ) );
	if( N.z < 0 )
	{
		N.xy = ( 1 - abs(N.yx) ) * ( N.xy >= 0 ? float2(1,1) : float2(-1,-1) );
	}
	return normalize(N);
}

由於以上被壓縮的向量要求是單位長度,所以只能壓縮入射光方向、視線、法線等向量,對於顏色、光照強度等含有長度資訊的向量是無法準確壓縮的。

此外,UE還支援了半八角面的編解碼:

// Engine\Shaders\Private\DeferredShadingCommon.ush

// 3維單位向量壓縮成半八面體的2維向量
float2 UnitVectorToHemiOctahedron( float3 N )
{
	N.xy /= dot( 1, abs(N) );
	return float2( N.x + N.y, N.x - N.y );
}

// 半八面體的2維向量解壓成3維單位向量
float3 HemiOctahedronToUnitVector( float2 Oct )
{
	Oct = float2( Oct.x + Oct.y, Oct.x - Oct.y ) * 0.5;
	float3 N = float3( Oct, 1 - dot( 1, abs(Oct) ) );
	return normalize(N);
}

1.3.5 基礎巨集定義

UE裡為了相容各個平臺的差異,以及編譯器的各類選項,定義了豐富多彩的巨集定義,主要集中在Definitions.h和Build.h檔案中:

// Engine\Intermediate\Build\Win64\UE4Editor\Development\Launch\Definitions.h

#define IS_PROGRAM 0
#define UE_EDITOR 1
#define ENABLE_PGO_PROFILE 0
#define USE_VORBIS_FOR_STREAMING 1
#define USE_XMA2_FOR_STREAMING 1
#define WITH_DEV_AUTOMATION_TESTS 1
#define WITH_PERF_AUTOMATION_TESTS 1
#define UNICODE 1
#define _UNICODE 1
#define __UNREAL__ 1
#define IS_MONOLITHIC 0
#define WITH_ENGINE 1
#define WITH_UNREAL_DEVELOPER_TOOLS 1
#define WITH_APPLICATION_CORE 1
#define WITH_COREUOBJECT 1
#define USE_STATS_WITHOUT_ENGINE 0
#define WITH_PLUGIN_SUPPORT 0
#define WITH_ACCESSIBILITY 1
#define WITH_PERFCOUNTERS 1
#define USE_LOGGING_IN_SHIPPING 0
#define WITH_LOGGING_TO_MEMORY 0
#define USE_CACHE_FREED_OS_ALLOCS 1
#define USE_CHECKS_IN_SHIPPING 0
#define WITH_EDITOR 1
#define WITH_SERVER_CODE 1
#define WITH_PUSH_MODEL 0
#define WITH_CEF3 1
#define WITH_LIVE_CODING 1
#define WITH_XGE_CONTROLLER 1
#define UBT_MODULE_MANIFEST "UE4Editor.modules"
#define UBT_MODULE_MANIFEST_DEBUGGAME "UE4Editor-Win64-DebugGame.modules"
#define UBT_COMPILED_PLATFORM Win64
#define UBT_COMPILED_TARGET Editor
#define UE_APP_NAME "UE4Editor"
#define NDIS_MINIPORT_MAJOR_VERSION 0
#define WIN32 1
#define _WIN32_WINNT 0x0601
#define WINVER 0x0601
#define PLATFORM_WINDOWS 1
#define PLATFORM_MICROSOFT 1
#define OVERRIDE_PLATFORM_HEADER_NAME Windows
#define RHI_RAYTRACING 1
#define NDEBUG 1
#define UE_BUILD_DEVELOPMENT 1
#define UE_IS_ENGINE_MODULE 1
#define WITH_LAUNCHERCHECK 0
#define UE_BUILD_DEVELOPMENT_WITH_DEBUGGAME 0
#define UE_ENABLE_ICU 1
#define WITH_VS_PERF_PROFILER 0
#define WITH_DIRECTXMATH 0
#define WITH_MALLOC_STOMP 1
#define CORE_API DLLIMPORT
#define TRACELOG_API DLLIMPORT
#define COREUOBJECT_API DLLIMPORT
#define INCLUDE_CHAOS 0
#define WITH_PHYSX 1
#define WITH_CHAOS 0
#define WITH_CHAOS_CLOTHING 0
#define WITH_CHAOS_NEEDS_TO_BE_FIXED 0
#define PHYSICS_INTERFACE_PHYSX 1
#define WITH_APEX 1
#define WITH_APEX_CLOTHING 1
#define WITH_CLOTH_COLLISION_DETECTION 1
#define WITH_PHYSX_COOKING 1
#define WITH_NVCLOTH 1
#define WITH_CUSTOM_SQ_STRUCTURE 0
#define WITH_IMMEDIATE_PHYSX 0
#define GPUPARTICLE_LOCAL_VF_ONLY 0
#define ENGINE_API DLLIMPORT
#define NETCORE_API DLLIMPORT
#define APPLICATIONCORE_API DLLIMPORT
#define DDPI_EXTRA_SHADERPLATFORMS SP_XXX=32, 
#define DDPI_SHADER_PLATFORM_NAME_MAP { TEXT("XXX"), SP_XXX },
#define RHI_API DLLIMPORT
#define JSON_API DLLIMPORT
#define WITH_FREETYPE 1
#define SLATECORE_API DLLIMPORT
#define INPUTCORE_API DLLIMPORT
#define SLATE_API DLLIMPORT
#define WITH_UNREALPNG 1
#define WITH_UNREALJPEG 1
#define WITH_UNREALEXR 1
#define IMAGEWRAPPER_API DLLIMPORT
#define MESSAGING_API DLLIMPORT
#define MESSAGINGCOMMON_API DLLIMPORT
#define RENDERCORE_API DLLIMPORT
#define ANALYTICSET_API DLLIMPORT
#define ANALYTICS_API DLLIMPORT
#define SOCKETS_PACKAGE 1
#define SOCKETS_API DLLIMPORT
#define ASSETREGISTRY_API DLLIMPORT
#define ENGINEMESSAGES_API DLLIMPORT
#define ENGINESETTINGS_API DLLIMPORT
#define SYNTHBENCHMARK_API DLLIMPORT
#define RENDERER_API DLLIMPORT
#define GAMEPLAYTAGS_API DLLIMPORT
#define PACKETHANDLER_API DLLIMPORT
#define RELIABILITYHANDLERCOMPONENT_API DLLIMPORT
#define AUDIOPLATFORMCONFIGURATION_API DLLIMPORT
#define MESHDESCRIPTION_API DLLIMPORT
#define STATICMESHDESCRIPTION_API DLLIMPORT
#define PAKFILE_API DLLIMPORT
#define RSA_API DLLIMPORT
#define NETWORKREPLAYSTREAMING_API DLLIMPORT


// Engine\Source\Runtime\Core\Public\Misc\Build.h

#ifndef UE_BUILD_DEBUG
	#define UE_BUILD_DEBUG				0
#endif
#ifndef UE_BUILD_DEVELOPMENT
	#define UE_BUILD_DEVELOPMENT		0
#endif
#ifndef UE_BUILD_TEST
	#define UE_BUILD_TEST				0
#endif
#ifndef UE_BUILD_SHIPPING
	#define UE_BUILD_SHIPPING			0
#endif
#ifndef UE_GAME
	#define UE_GAME						0
#endif
#ifndef UE_EDITOR
	#define UE_EDITOR					0
#endif
#ifndef UE_BUILD_SHIPPING_WITH_EDITOR
	#define UE_BUILD_SHIPPING_WITH_EDITOR 0
#endif
#ifndef UE_BUILD_DOCS
	#define UE_BUILD_DOCS				0
#endif

(......)

其中常見的基礎巨集及說明如下:

巨集名稱 解析 預設值
UE_EDITOR 當前程式是否編輯器,使用得最普遍 1
WITH_ENGINE 是否啟用引擎,如果不是,則類似SDK只提供基礎API,很多模組將不能正常使用。 1
WITH_EDITOR 是否啟用編輯器,跟UE_EDITOR類似。 1
WIN32 是否win32位程式。 1
PLATFORM_WINDOWS 是否Windows操作平臺。 1
UE_BUILD_DEBUG 除錯構建模式。 0
UE_BUILD_DEVELOPMENT 開發者構建模式。 1
UE_BUILD_SHIPPING 釋出版構建模式。 0
UE_GAME 遊戲構建模式。 0
UE_EDITOR 編輯器構建模式。 0
UE_BUILD_DEVELOPMENT_WITH_DEBUGGAME 攜帶遊戲除錯的開發者構建模式。 0
UE_BUILD_SHIPPING_WITH_EDITOR 攜帶編輯器的釋出版構建模式。 0
UE_BUILD_DOCS 文件構建模式。 0
RHI_RAYTRACING 是否開啟光線追蹤 1

 

1.4 引擎模組

本小節將過一遍UE的基礎體系和概念,以便對UE不熟悉的讀者可以有個大概的瞭解,以便更好地切入渲染模組。

1.4.1 Object , Actor, ActorComponent

UObject是UE所有物體型別的基類,它繼承於UObjectBaseUtility,而UObjectBaseUtility又繼承於UObjectBase。它提供了後設資料、反射生成、GC垃圾回收、序列化、部分編輯器資訊、物體建立銷燬、事件回撥等功能,子類具體的型別由UClass描述而定。它們的繼承關係如下圖:

AActor是UE體系中最主要且最重要的概念和型別,繼承自UObject,是所有可以放置到遊戲關卡中的物體的基類,相當於Unity引擎的GameObject。它提供了網路同步(Replication)、建立銷燬物體、幀更新(Tick)、元件操作、Actor巢狀操作、變換等功能。AActor物件是可以巢狀AActor物件的,由以下介面提供支援:

// Engine\Source\Runtime\Engine\Classes\GameFramework\Actor.h

void AttachToActor(AActor* ParentActor, ... );
void AttachToComponent(USceneComponent* Parent, ... );

以上兩個介面其實是等價的,因為實際上AActor::AttachToActor的實現程式碼呼叫的也是RootComponent::AttachToComponent介面:

// Engine\Source\Runtime\Engine\Private\Actor.cpp

void AActor::AttachToActor(AActor* ParentActor, const FAttachmentTransformRules& AttachmentRules, FName SocketName)
{
	if (RootComponent && ParentActor)
	{
		USceneComponent* ParentDefaultAttachComponent = ParentActor->GetDefaultAttachComponent();
		if (ParentDefaultAttachComponent)
		{
			RootComponent->AttachToComponent(ParentDefaultAttachComponent, AttachmentRules, SocketName);
		}
	}
}

也就是說Actor自身不具有巢狀功能,但可以通過擁有一對一關係的RootSceneComponent達成。

繼承自Actor的常見子類有:

  • ASkeletalMeshActor:蒙皮骨骼體,用於渲染帶骨骼蒙皮的動態模型。
  • AStaticMeshActor:靜態模型。
  • ACameraActor:攝像機物體。
  • APlayerCameraManager:攝像機管理器,管理著當前世界所有的攝像機(ACameraActor)例項。
  • ALight:燈光物體,下面又衍生出點光源(APointLight)、平行光(ADirectionalLight)、聚光燈(ASpotLight)、矩形光(ARectLight)等型別。
  • AReflectionCapture:反射捕捉器,用於離線生成環境圖。
  • AController:角色控制器。下面還衍生出AAIController、APlayerController等子類。
  • APawn:描述動態角色或帶有AI的物體。它的子類還有ACharacter、ADefaultPawn、AWheeledVehicle等。
  • AMaterialInstanceActor:材質例項體。
  • ALightmassPortal:全域性光照入口,用於加速和提升離線全域性的光照效率和效果。
  • AInfo:配置資訊類的基類,繼承自它的常見子類有AWorldSettings、AGameModeBase、AAtmosphericFog、ASkyAtmosphere、ASkyLight等。
  • ......

以上只是列出部分AActor的子類,可知它們有些可以放入關卡,但有些並不能直接放入關卡。它們的部分繼承體系如下圖:

UActorComponent繼承自UObject和介面IInterface_AssetUserData,是所有元件型別的基類,可以作為子節點加入到AActor例項中。可以更加直觀地說,Actor可被視為包含一系列元件的容器,Actor的功能特性和性質主要由附加在它身上的元件們決定。

常用的主要的UActorComponent子元件型別有:

  • USceneComponent:SceneComponents是擁有變換的ActorComponents。變換是場景中的位置,由位置、旋轉和縮放定義。SceneComponents能以層級的方式相互附加。Actor的位置、旋轉和縮放取自位於層級根部的SceneComponent。
  • UPrimitiveComponent:繼承自SceneComponent,是所有可見(可渲染,如網格體或粒子系統)物體的基類,還提供了物理、碰撞、燈光通道等功能。
  • UMeshComponent:繼承自UPrimitiveComponent,所有具有可渲染三角形網格集合(靜態模型、動態模型、程式生成模型)的基類。
  • UStaticMeshComponent:繼承自UMeshComponent,是靜態網格體的幾何體,常用於建立UStaticMesh例項。
  • USkinnedMeshComponent:繼承自UMeshComponent,支援蒙皮網格渲染的元件,提供網格、骨骼資源、網格LOD等介面。
  • USkeletalMeshComponent:繼承自USkinnedMeshComponent,通常用於建立帶動畫的USkeletalMesh資源的例項。

它們的繼承關係如下圖:

所有可放置到關卡的Actor 都有一個 Root Component(Scene Component的一種),能夠作為場景元件的任意子類。場景元件(Scene Component) 指定了 Actor 在世界中的位置、角度及縮放比例,而這些屬性會影響該 Actor 的所有子物件。

即便是一個空 Actor,也擁有一個"預設場景根(Default Scene Root)"物件,這是一個最簡單的場景元件。在編輯器操作階段,當我們給某個Actor放置一個新的場景元件時,該Actor的預設場景根物件會被替換掉。

Actor, RootComponent, SceneComponent, ActorComponent層級巢狀示意圖。

1.4.2 Level, World, WorldContext, Engine

ULevel是UE的關卡,是場景中物體的集合,儲存著一系列Actor,包含可見物體(如網格體、燈光、特效等)以及不可見物體(如體積、藍圖、關卡配置、導航資料等)。

UWorld是ULevel的容器,它才真正地代表著一個場景,因為ULevel必須放置到UWorld才能顯示出其內容。每個UWorld例項必須包含一個主關卡(Persistent Level),還可能包含若干個流式關卡(Streaming Level,可選,非必需,可按需動態載入和解除安裝)。除了關卡資訊,UWorld還儲存著Scene、GameInstance、AISystem、FXSystem、NavigationSystem、PhysicScene、TimerManager等等資訊。它有以下幾種型別:

// Engine\Source\Runtime\Engine\Classes\Engine\EngineTypes.h

namespace EWorldType
{
	enum Type
	{
		None,
        
		Game,
		Editor,
		PIE,
		EditorPreview,
		GamePreview,
		GameRPC,

		Inactive
	};
}

常見的WorldType有遊戲(Game)、編輯器(Editor)、編輯器播放(PIE)以及預覽模式(EditorPreview、GamePreview)等。我們平常用的編輯器內的場景其實也是個World,型別為Editor。

FWorldContext是引擎層面處理Level的裝置上下文,方便UEngine管理和記錄World關聯的資訊。用於內部類,不應該被邏輯層直接操作。它儲存的資料有World型別、ContextHandle、GameInstance、GameViewport等等資訊。

UEngine控制和掌管著很多內部系統及資源,下派生出UGameEngine和UEditorEngine。它是一個單例的全域性變數:

// Engine\Source\Runtime\Engine\Classes\Engine\Engine.h

/** Global engine pointer. Can be 0 so don't use without checking. */
extern ENGINE_API class UEngine* GEngine;

它是在程式啟動之初在FEngineLoop::PreInitPostStartupScreen被建立並賦值的:

// Engine\Source\Runtime\Launch\Private\LaunchEngineLoop.cpp

int32 FEngineLoop::PreInitPostStartupScreen(const TCHAR* CmdLine)
{
	(......)

			if ( GEngine == nullptr )
			{
#if WITH_EDITOR
				if ( GIsEditor )
				{
					FString EditorEngineClassName;
					GConfig->GetString(TEXT("/Script/Engine.Engine"), TEXT("EditorEngine"), EditorEngineClassName, GEngineIni);
					UClass* EditorEngineClass = StaticLoadClass( UEditorEngine::StaticClass(), nullptr, *EditorEngineClassName);
					
                    // 建立編輯器引擎例項
					GEngine = GEditor = NewObject<UEditorEngine>(GetTransientPackage(), EditorEngineClass);

					(......)
				}
				else
#endif
				{
					FString GameEngineClassName;
					GConfig->GetString(TEXT("/Script/Engine.Engine"), TEXT("GameEngine"), GameEngineClassName, GEngineIni);

					UClass* EngineClass = StaticLoadClass( UEngine::StaticClass(), nullptr, *GameEngineClassName);

					// 建立遊戲引擎例項
					GEngine = NewObject<UEngine>(GetTransientPackage(), EngineClass);

					(......)
				}
			}
	
    (......)
    
	return 0;
}

從上面可以看到,會根據是否編輯器模式建立UEditorEngine或UGameEngine例項,然後賦值給全域性變數GEngine,GEngine便可以被其它地方的程式碼直接訪問。

ULevel、UWorld、FWorldContext、UEngine之間的繼承、依賴、引用關係如下圖所示:

1.4.3 記憶體分配

UE的記憶體分配體系可謂龐大且複雜,提供的功能總結起來有:

  • 封裝系統平臺之間的差異,提供統一介面。
  • 按某種規則高效地建立、回收記憶體,可有效提升記憶體操作效率。
  • 支援多種記憶體分配方案,各取所需。
  • 支援多種呼叫方式,應對不同的場景。
  • 支援多執行緒安全的記憶體操作。
  • 部分支援TLS(執行緒區域性快取)。
  • 支援GPU記憶體的統一管理。
  • 提供記憶體除錯、統計資訊。
  • 良好的擴充套件性。

那麼,UE是怎麼做到以上這些目標的呢?下面將為大家揭祕。

1.4.3.1 記憶體分配基礎

為了後面更好地講解記憶體分配方案,本小節先闡述一下涉及到的基本概念。

  • FFreeMem

    可分配的小塊記憶體資訊記錄體,在FMallocBinned定義如下:

    struct FMallocBinned::FFreeMem
    {
    	FFreeMem*	Next;			// 指向下一塊記憶體或結尾. 由記憶體池(pool)管理, 保證有序.
    	uint32		NumFreeBlocks;	// 可分配的連續記憶體塊數量, 至少為1.
    	uint32		Padding;		// 讓結構體16位元組對齊的填補位元組.
    };
    
  • FPoolInfo

    記憶體池,在常用的記憶體分配器中,為了減少系統操作記憶體的開銷,通常會先分配一塊較大的記憶體,然後再在此大記憶體分割成若干小塊(UE的小塊記憶體是相等尺寸)。

    為什麼要先分配大塊記憶體再切割成若干小塊?

    《遊戲引擎架構》第5章記憶體管理章節給出了深入且明確的答案,總結起來理由如下:

    1、記憶體分配器通常在堆裡操作,過程比較緩慢。它是通用的裝置,如果直接申請,必須處理任何大小的分配請求,導致作業系統大量的管理開銷。

    2、多數作業系統上,呼叫系統記憶體操作會從使用者態切換到核心態,處理完請求再切換到使用者態,這些狀態之間的切換會耗費很多時間。

    Windows作業系統的使用者態和核心態通訊示意圖,可見它們之間的通訊需經由多層驅動。

    這種分配方式可有效提升記憶體分配效率,可全域性管理所有的記憶體操作(GC、優化、碎片整理、及時釋放等)。但也有一定的副作用,比如不可避免一定比例的記憶體空間的浪費,瞬時IO的增加,記憶體碎片的形成(可定期整理)等。

    FPoolInfo在FMallocBinned定義如下:

    struct FMallocBinned::FPoolInfo
    {
    	uint16			Taken;		// 已分配的記憶體塊數量.
    	uint16			TableIndex; // 所在的MemSizeToPoolTable索引.
    	uint32			AllocSize;	// 已分配的記憶體大小.
        
    	FFreeMem*		FirstMem;   // 如果是裝箱模式, 指向記憶體池可用的記憶體塊; 如果非裝箱模式, 指向由作業系統直接分配的記憶體塊.
    	FPoolInfo*		Next;		// 指向下一個記憶體池.
    	FPoolInfo**		PrevLink;	// 指向上一個記憶體池.
    };
    

    由於記憶體池內的記憶體塊(Block)是等尺寸的,所以記憶體池的記憶體分佈示意圖如下:

  • FPoolTable

    記憶體池表,採用雙向連結串列儲存了一組記憶體池。當記憶體池表中的記憶體池無法沒有可分配的記憶體塊時,就會重新建立一個記憶體池,加入雙向連結串列中。

    FPoolTable在FMallocBinned定義如下:

    struct FMallocBinned::FPoolTable
    {
    	FPoolInfo*			FirstPool;		// 初始記憶體池, 是雙向連結串列的表頭.
    	FPoolInfo*			ExhaustedPool;	// 已經耗盡(沒有可分配的記憶體)的記憶體池連結串列
    	uint32				BlockSize;		// 記憶體塊大小
    };
    

    FPoolTable的資料結構示意圖:

  • PoolHashBucket

    記憶體池雜湊桶,用於存放由記憶體地址雜湊出來的鍵對應的所有記憶體池。PoolHashBucket在FMallocBinned定義如下:

    struct FMallocBinned::PoolHashBucket
    {
    	UPTRINT			Key;		// 雜湊鍵
    	FPoolInfo*		FirstPool;	// 指向第一塊記憶體池
    	PoolHashBucket* Prev;		// 上一個記憶體池雜湊桶
    	PoolHashBucket* Next;		// 下一個記憶體池雜湊桶
    };
    

    它的資料結構示意圖如下:

  • 記憶體尺寸

    UE的記憶體尺寸涉及的引數比較多,有記憶體池大小(PoolSize)、記憶體頁大小(PageSize)和記憶體塊(BlockSize),它們的實際大小與分配器、系統平臺、記憶體對齊方式、呼叫者都有關係。下面是FMallocBinned定義的部分記憶體相關變數的大小:

    #if PLATFORM_IOS
    	#define PLAT_PAGE_SIZE_LIMIT       16384
    	#define PLAT_BINNED_ALLOC_POOLSIZE 16384
    	#define PLAT_SMALL_BLOCK_POOL_SIZE 256
    #else
    	#define PLAT_PAGE_SIZE_LIMIT       65536
    	#define PLAT_BINNED_ALLOC_POOLSIZE 65536
    	#define PLAT_SMALL_BLOCK_POOL_SIZE 0
    #endif
    

    由此可知,在IOS平臺下,記憶體頁上限和記憶體池大小是16k,裝箱記憶體塊大小是256位元組;其它平臺下,記憶體頁上限和記憶體池大小是64k,裝箱記憶體塊大小是0位元組。

1.4.3.2 記憶體分配器

FMalloc是UE記憶體分配器的核心類,掌控著UE所有的記憶體分配和釋放操作。然而它是個虛基類,它繼承自FUseSystemMallocForNew和FExec,同時也有多個子類,分別對應不同的記憶體分配方案和策略。FMalloc的主體繼承關係如下圖:

上圖只展示了部分FMalloc的子類,其它除錯和輔助類不在此圖。FMalloc繼承體系主要類的解析如下:

  • FUseSystemMallocForNew

    FUseSystemMallocForNew提供了new和delete關鍵字的操作符支援,而FMalloc繼承了FUseSystemMallocForNew,意味著FMalloc的所有子類都支援C++的new和delete等關鍵字的記憶體操作。

  • FMallocAnsi

    標準分配器,直接呼叫C的malloc和free操作,未做任何的記憶體快取和分配策略管理。

  • FMallocBinned

    標準(舊有)的裝箱管理方式,啟用了記憶體池表(FPoolTable)、頁面記憶體池表(FPagePoolTable)和記憶體池雜湊桶(PoolHashBucket),是UE預設的記憶體分配方式,也是支援所有平臺的一種記憶體分配方式。它的核心定義如下:

    // Engine\Source\Runtime\Core\Public\HAL\MallocBinned.h
    
    class FMallocBinned : public FMalloc
    {
    private:
    	enum { POOL_COUNT = 42 };
    	enum { EXTENDED_PAGE_POOL_ALLOCATION_COUNT = 2 };
    	enum { MAX_POOLED_ALLOCATION_SIZE   = 32768+1 };
    	
        (......)
    
    	FPoolTable  PoolTable[POOL_COUNT];	// 所有的記憶體池表列表, 單個記憶體池的Block尺寸是一樣的.
    	FPoolTable	OsTable;	// 管理由系統直接分配的記憶體的記憶體池表. 不過研讀原始碼後發現並未使用.
    	FPoolTable	PagePoolTable[EXTENDED_PAGE_POOL_ALLOCATION_COUNT];	// 記憶體頁(非小塊記憶體)的記憶體池表.
    	FPoolTable* MemSizeToPoolTable[MAX_POOLED_ALLOCATION_SIZE+EXTENDED_PAGE_POOL_ALLOCATION_COUNT];	// 根據尺寸索引的記憶體池表, 實際會指向PoolTable和PagePoolTable.
    	
    	PoolHashBucket* HashBuckets;		// 記憶體池雜湊桶
    	PoolHashBucket* HashBucketFreeList;	// 可分配的記憶體池雜湊桶
    	
    	uint32		PageSize;	// 記憶體頁大小
        
        (......)
    };
    

    為了更好地理解後續的記憶體分配機制,這裡先分析一下記憶體分配器的初始化程式碼:

    // Engine\Source\Runtime\Core\Private\HAL\MallocBinned.cpp
    
    FMallocBinned::FMallocBinned(uint32 InPageSize, uint64 AddressLimit)
    {
        (......)
        
        // 裝箱的最大尺寸為8k(IOS)或32k(非IOS平臺).
        BinnedSizeLimit = Private::PAGE_SIZE_LIMIT/2;
        
    	(......)
        
        // 初始化記憶體頁的記憶體池1, 預設情況下, 它的BlockSize為12k(IOS)或48k(非IOS平臺).
    	PagePoolTable[0].FirstPool = nullptr;
    	PagePoolTable[0].ExhaustedPool = nullptr;
    	PagePoolTable[0].BlockSize = PageSize == Private::PAGE_SIZE_LIMIT ? BinnedSizeLimit+(BinnedSizeLimit/2) : 0;
    	
        // 初始化記憶體頁的記憶體池2, 預設情況下, 它的BlockSize為24k(IOS)或96k(非IOS平臺).
    	PagePoolTable[1].FirstPool = nullptr;
    	PagePoolTable[1].ExhaustedPool = nullptr;
    	PagePoolTable[1].BlockSize = PageSize == Private::PAGE_SIZE_LIMIT ? PageSize+BinnedSizeLimit : 0;
    
        // 用來建立不同BlockSize的數字陣列, 它們遵循兩個規則: 1. 儘可能是記憶體池尺寸的整除數(因子), 減少記憶體浪費; 2. 必須16位對齊.
    	static const uint32 BlockSizes[POOL_COUNT] =
    	{
    		8,		16,		32,		48,		64,		80,		96,		112,
    		128,	160,	192,	224,	256,	288,	320,	384,
    		448,	512,	576,	640,	704,	768,	896,	1024,
    		1168,	1360,	1632,	2048,	2336,	2720,	3264,	4096,
    		4672,	5456,	6544,	8192,	9360,	10912,	13104,	16384,
    		21840,	32768
    	};
    	
        // 建立記憶體塊的記憶體池表, 並根據BlockSizes初始化BlockSize
    	for( uint32 i = 0; i < POOL_COUNT; i++ )
    	{
    		PoolTable[i].FirstPool = nullptr;
    		PoolTable[i].ExhaustedPool = nullptr;
    		PoolTable[i].BlockSize = BlockSizes[i];
    #if STATS
    		PoolTable[i].MinRequest = PoolTable[i].BlockSize;
    #endif
    	}
    	
        // 初始化MemSizeToPoolTable, 將所有大小的記憶體池表指向PoolTable.
    	for( uint32 i=0; i<MAX_POOLED_ALLOCATION_SIZE; i++ )
    	{
    		uint32 Index = 0;
    		while( PoolTable[Index].BlockSize < i )
    		{
    			++Index;
    		}
    		checkSlow(Index < POOL_COUNT);
    		MemSizeToPoolTable[i] = &PoolTable[Index];
    	}
    	
        // 將記憶體頁的記憶體池表新增到MemSizeToPoolTable陣列的末尾.
    	MemSizeToPoolTable[BinnedSizeLimit] = &PagePoolTable[0];
    	MemSizeToPoolTable[BinnedSizeLimit+1] = &PagePoolTable[1];
    
    	check(MAX_POOLED_ALLOCATION_SIZE - 1 == PoolTable[POOL_COUNT - 1].BlockSize);
    }
    

    為了更加清晰直觀地說明MemSizeToPoolTable、PoolTable和PagePoolTable之間的關係和記憶體分佈,筆者特意繪製了下面的示意圖:

    FMallocBinned分配記憶體的主體程式碼和解析如下:

    // Engine\Source\Runtime\Core\Private\HAL\MallocBinned.cpp
    
    void* FMallocBinned::Malloc(SIZE_T Size, uint32 Alignment)
    {
    	(......)
        
        // 處理記憶體對齊, 並根據記憶體對齊調整Size
        if (Alignment == DEFAULT_ALIGNMENT)
    	{
            // 預設的記憶體對齊是16位元組
    		Alignment = Private::DEFAULT_BINNED_ALLOCATOR_ALIGNMENT;
    	}
    	Alignment = FMath::Max<uint32>(Alignment, Private::DEFAULT_BINNED_ALLOCATOR_ALIGNMENT);
    	SIZE_T SpareBytesCount = FMath::Min<SIZE_T>(Private::DEFAULT_BINNED_ALLOCATOR_ALIGNMENT, Size);
        Size = FMath::Max<SIZE_T>(PoolTable[0].BlockSize, Size + (Alignment - SpareBytesCount));
        
        (......)
        
        FFreeMem* Free = nullptr;
    	bool bUsePools = true;	// 預設使用記憶體池
        
        (......)
        
    	if (bUsePools)
    	{
            // 如果分配的尺寸小於BinnedSizeLimit(32k), 說明是記憶體碎片, 放入MemSizeToPoolTable的FPoolTable中.
    		if( Size < BinnedSizeLimit)
    		{
    			// Allocate from pool.
    			FPoolTable* Table = MemSizeToPoolTable[Size];
    #ifdef USE_FINE_GRAIN_LOCKS
    			FScopeLock TableLock(&Table->CriticalSection);
    #endif
    			checkSlow(Size <= Table->BlockSize);
    
    			Private::TrackStats(Table, (uint32)Size);
    
    			FPoolInfo* Pool = Table->FirstPool;
    			if( !Pool )
    			{
    				Pool = Private::AllocatePoolMemory(*this, Table, Private::BINNED_ALLOC_POOL_SIZE/*PageSize*/, Size);
    			}
    
    			Free = Private::AllocateBlockFromPool(*this, Table, Pool, Alignment);
    		}
            // 如果分配的尺寸處於BinnedSizeLimit(32k)和PagePoolTable[0].BlockSize(48k)之間, 或者處於PageSize(64k)和PagePoolTable[1].BlockSize(96k)之間, 由PagePoolTable頁記憶體池表中.
    		else if ( ((Size >= BinnedSizeLimit && Size <= PagePoolTable[0].BlockSize) ||
    				   (Size > PageSize && Size <= PagePoolTable[1].BlockSize)))
    		{
    			// Bucket in a pool of 3*PageSize or 6*PageSize
    			uint32 BinType = Size < PageSize ? 0 : 1;
    			uint32 PageCount = 3*BinType + 3;
    			FPoolTable* Table = &PagePoolTable[BinType];
    #ifdef USE_FINE_GRAIN_LOCKS
    			FScopeLock TableLock(&Table->CriticalSection);
    #endif
    			checkSlow(Size <= Table->BlockSize);
    
    			Private::TrackStats(Table, (uint32)Size);
    
    			FPoolInfo* Pool = Table->FirstPool;
    			if( !Pool )
    			{
    				Pool = Private::AllocatePoolMemory(*this, Table, PageCount*PageSize, BinnedSizeLimit+BinType);
    			}
    
    			Free = Private::AllocateBlockFromPool(*this, Table, Pool, Alignment);
    		}
            // 超過了記憶體頁尺寸, 直接由系統分配記憶體, 且放入HashBuckets表中.
    		else
    		{
    			// Use OS for large allocations.
    			UPTRINT AlignedSize = Align(Size,PageSize);
    			SIZE_T ActualPoolSize; //TODO: use this to reduce waste?
    			Free = (FFreeMem*)Private::OSAlloc(*this, AlignedSize, ActualPoolSize);
    			if( !Free )
    			{
    				Private::OutOfMemory(AlignedSize);
    			}
    
    			void* AlignedFree = Align(Free, Alignment);
    
    			// Create indirect.
    			FPoolInfo* Pool;
    			{
    #ifdef USE_FINE_GRAIN_LOCKS
    				FScopeLock PoolInfoLock(&AccessGuard);
    #endif
    				Pool = Private::GetPoolInfo(*this, (UPTRINT)Free);
    
    				if ((UPTRINT)Free != ((UPTRINT)AlignedFree & ~((UPTRINT)PageSize - 1)))
    				{
    					// Mark the FPoolInfo for AlignedFree to jump back to the FPoolInfo for ptr.
    					for (UPTRINT i = (UPTRINT)PageSize, Offset = 0; i < AlignedSize; i += PageSize, ++Offset)
    					{
    						FPoolInfo* TrailingPool = Private::GetPoolInfo(*this, ((UPTRINT)Free) + i);
    						check(TrailingPool);
    						//Set trailing pools to point back to first pool
    						TrailingPool->SetAllocationSizes(0, 0, Offset, BinnedOSTableIndex);
    					}
    				}
    			}
    			Free = (FFreeMem*)AlignedFree;
    			Pool->SetAllocationSizes(Size, AlignedSize, BinnedOSTableIndex, BinnedOSTableIndex);
    			
                (......)
    		}
    	}
    
    	return Free;
    }
    

    綜上程式碼,在非IOS平臺且預設頁面尺寸(64k)的情況下,FMallocBinned的分配策略簡述如下:

    • 待分配記憶體的大小處於(0, 32k),使用MemSizeToPoolTable的PoolTable分配和儲存。

    • 待分配記憶體的大小處於[32k, 48K]或者[64k, 96k],使用PagePoolTable的PoolTable分配和儲存。

    • 其它待分配記憶體的大小直接使用系統分配,且放入HashBuckets中。

      為什麼UE要將大小在(48k, 64k)的記憶體直接交給系統分配,而不用裝箱方式呢?

      由於FMallocBinned的記憶體池是等分切割的,如果(48k, 64k)之間的記憶體用裝箱的方式分配,必須放入到64k的記憶體池,由此將帶來(0, 16k)之間的記憶體浪費。也就是說,在最壞情況下,這個區間裡的每次記憶體分配,都會浪費16k記憶體,記憶體浪費比例達到了驚人的33.33%。這對於講究高效能的UE官方團隊來說,明顯是不容許的。兩害相權取其輕,利害權衡之後,才有了此策略。

      當然,這裡存在優化空間,那就是處於(48k, 64k)之間的記憶體可由更小的Block拼裝起來。比如,50k的記憶體可放進BlockSize為2k的記憶體池裡,佔用25個Block即可(但同時會加重記憶體池和記憶體池表的管理複雜度)。

    FMallocBinned和下面提及的FMallocBinned2、FMallocBinned3實際上就是預先分配大記憶體,然後在大記憶體中再分配合適的小記憶體塊。這些方式雖然可提高記憶體分配效率,但是瞬時io壓力會變大,也不可避免地出現記憶體浪費。

    FMallocBinned的記憶體浪費主要體現在以下幾點:

    1、新分配的記憶體池往往不能立即被全部利用,導致了一定程式的冗餘。

    2、由於記憶體對齊和尺寸對齊,很多連續大小的記憶體塊向上對映到同一個尺寸的記憶體池表(如大小為[9, 16]的記憶體塊都對映到BlockSize為16的記憶體池表),這也導致了一定比例的記憶體浪費。

    3、維護分配器的記憶體池表、記憶體池、雜湊桶、記憶體塊等等資訊額外產生的記憶體。

  • FMallocBinned2

    新的箱裝記憶體分配方式,從原始碼上分析可知,FMallocBinned2比FMallocBinned的分配方式會簡單一些,會根據小塊記憶體、對齊大小和是否開啟執行緒快取(預設開啟)選擇對應分配器和策略。

  • FMallocBinned3

    僅64位系統可用的新型箱裝記憶體分配方式。實現方式和FMallocBinned2類似,支援執行緒快取。

  • FMallocTBB

    FMallocTBB採納的是第三方記憶體分配器TBB中的scalable_allocator分配器,scalable_allocator提供的介面如下:

    // Engine\Source\ThirdParty\IntelTBB\IntelTBB-2019u8\include\tbb\scalable_allocator.h
    
    void * __TBB_EXPORTED_FUNC scalable_malloc (size_t size);
    void   __TBB_EXPORTED_FUNC scalable_free (void* ptr);
    void * __TBB_EXPORTED_FUNC scalable_realloc (void* ptr, size_t size);
    void * __TBB_EXPORTED_FUNC scalable_calloc (size_t nobj, size_t size);
    int __TBB_EXPORTED_FUNC scalable_posix_memalign (void** memptr, size_t alignment, size_t size);
    void * __TBB_EXPORTED_FUNC scalable_aligned_malloc (size_t size, size_t alignment);
    void * __TBB_EXPORTED_FUNC scalable_aligned_realloc (void* ptr, size_t size, size_t alignment);
    void __TBB_EXPORTED_FUNC scalable_aligned_free (void* ptr);
    size_t __TBB_EXPORTED_FUNC scalable_msize (void* ptr);
    

    FMallocTBB正是使用了以上的scalable_aligned_malloc介面實現記憶體操作,其中分配程式碼如下:

    // Engine\Source\Runtime\Core\Private\HAL\MallocTBB.cpp
    
    void* FMallocTBB::TryMalloc( SIZE_T Size, uint32 Alignment )
    {
        (......)
    
    	void* NewPtr = nullptr;
    
    	if( Alignment != DEFAULT_ALIGNMENT )
    	{
    		Alignment = FMath::Max(Size >= 16 ? (uint32)16 : (uint32)8, Alignment);
    		NewPtr = scalable_aligned_malloc( Size, Alignment );
    	}
    	else
    	{
    		// Fulfill the promise of DEFAULT_ALIGNMENT, which aligns 16-byte or larger structures to 16 bytes,
    		// while TBB aligns to 8 by default.
    		NewPtr = scalable_aligned_malloc( Size, Size >= 16 ? (uint32)16 : (uint32)8);
    	}
        
         (......)
    
    	return NewPtr;
    }
    

    TBB(Threading Building Blocks) 由Intel研發並提供SDK,它的特性有:

    • 提供tbb_allocator、scalable_allocator和cache_aligned_allocator三種分配方式。
    • 並行的演算法和資料結構。
    • 基於任務的記憶體排程器。
    • 對多執行緒友好,同時支援多個執行緒操作記憶體。scalable_allocator不在同一個記憶體池中分配記憶體,可以避免多執行緒競爭導致的消耗。
    • 快取處理效率比其它方式高,cache_aligned_allocator通過快取對齊,解決假共享的問題。
  • 其它記憶體分配器

    除了以上常用的基礎記憶體分配器之外,UE還附帶了FMallocDebug(除錯記憶體)、FMallocStomp(除錯非法記憶體操作)、FMallocJemalloc(適合多執行緒下的記憶體分配管理)以及GPU視訊記憶體相關的分配(FMallocBinnedGPU)等等。這些記憶體分配方式比較特殊,這裡就不詳述了,有興趣的讀者自行研讀原始碼。

1.4.3.3 記憶體操作方式

上小節闡述了記憶體的分配方式和策略技術,接下來說說記憶體使用方式。對呼叫者而言,有以下幾種方式操作記憶體:

  • GMalloc:GMalloc是全域性的記憶體分配器,在UE啟動之初就通過FPlatformMemory被建立:

    // Engine\Source\Runtime\Core\Private\HAL\UnrealMemory.cpp
    
    static int FMemory_GCreateMalloc_ThreadUnsafe()
    {
    	(......)
    
    	GMalloc = FPlatformMemory::BaseAllocator();
    	
    	(......)
    }
    

    FPlatformMemory在不同的作業系統對應不同的型別,比如在Windows系統下,實際上是FWindowsPlatformMemory

    // Engine\Source\Runtime\Core\Public\Windows\WindowsPlatformMemory.h
    
    struct CORE_API FWindowsPlatformMemory : public FGenericPlatformMemory
    {
        (......)
        
        static class FMalloc* BaseAllocator();
        
        (......)
    };
    
    typedef FWindowsPlatformMemory FPlatformMemory;
    

    從上面程式碼可以看出,GMalloc實際上就是FMalloc的例項,在不同的作業系統用FPlatformMemory建立不同的FMalloc子類,從而應用不同的記憶體分配策略。下面分析FWindowsPlatformMemory::BaseAllocator的程式碼:

    // Engine\Source\Runtime\Core\Private\Windows\WindowsPlatformMemory.cpp
    
    FMalloc* FWindowsPlatformMemory::BaseAllocator()
    {
    #if ENABLE_WIN_ALLOC_TRACKING
    	// This allows tracking of allocations that don't happen within the engine's wrappers.
    	// This actually won't be compiled unless bDebugBuildsActuallyUseDebugCRT is set in the
    	// build configuration for UBT.
    	_CrtSetAllocHook(WindowsAllocHook);
    #endif // ENABLE_WIN_ALLOC_TRACKING
    	
        // 根據巨集定義採納不同的記憶體分配策略
    	if (FORCE_ANSI_ALLOCATOR) //-V517
    	{
    		AllocatorToUse = EMemoryAllocatorToUse::Ansi;
    	}
    	else if ((WITH_EDITORONLY_DATA || IS_PROGRAM) && TBB_ALLOCATOR_ALLOWED) //-V517
    	{
    		AllocatorToUse = EMemoryAllocatorToUse::TBB;
    	}
    #if PLATFORM_64BITS
    	else if ((WITH_EDITORONLY_DATA || IS_PROGRAM) && MIMALLOC_ALLOCATOR_ALLOWED) //-V517
    	{
    		AllocatorToUse = EMemoryAllocatorToUse::Mimalloc;
    	}
    	else if (USE_MALLOC_BINNED3)
    	{
    		AllocatorToUse = EMemoryAllocatorToUse::Binned3;
    	}
    #endif
    	else if (USE_MALLOC_BINNED2)
    	{
    		AllocatorToUse = EMemoryAllocatorToUse::Binned2;
    	}
    	else
    	{
    		AllocatorToUse = EMemoryAllocatorToUse::Binned;
    	}
    	
    #if !UE_BUILD_SHIPPING
    	// If not shipping, allow overriding with command line options, this happens very early so we need to use windows functions
    	const TCHAR* CommandLine = ::GetCommandLineW();
    	
        // 根據命令列調整記憶體分配策略
    	if (FCString::Stristr(CommandLine, TEXT("-ansimalloc")))
    	{
    		AllocatorToUse = EMemoryAllocatorToUse::Ansi;
    	}
    #if TBB_ALLOCATOR_ALLOWED
    	else if (FCString::Stristr(CommandLine, TEXT("-tbbmalloc")))
    	{
    		AllocatorToUse = EMemoryAllocatorToUse::TBB;
    	}
    #endif
    #if MIMALLOC_ALLOCATOR_ALLOWED
    	else if (FCString::Stristr(CommandLine, TEXT("-mimalloc")))
    	{
    		AllocatorToUse = EMemoryAllocatorToUse::Mimalloc;
    	}
    #endif
    #if PLATFORM_64BITS
    	else if (FCString::Stristr(CommandLine, TEXT("-binnedmalloc3")))
    	{
    		AllocatorToUse = EMemoryAllocatorToUse::Binned3;
    	}
    #endif
    	else if (FCString::Stristr(CommandLine, TEXT("-binnedmalloc2")))
    	{
    		AllocatorToUse = EMemoryAllocatorToUse::Binned2;
    	}
    	else if (FCString::Stristr(CommandLine, TEXT("-binnedmalloc")))
    	{
    		AllocatorToUse = EMemoryAllocatorToUse::Binned;
    	}
    #if WITH_MALLOC_STOMP
    	else if (FCString::Stristr(CommandLine, TEXT("-stompmalloc")))
    	{
    		AllocatorToUse = EMemoryAllocatorToUse::Stomp;
    	}
    #endif // WITH_MALLOC_STOMP
    #endif // !UE_BUILD_SHIPPING
    	
        // 根據不同的型別建立FMalloc的子類物件。
    	switch (AllocatorToUse)
    	{
    	case EMemoryAllocatorToUse::Ansi:
    		return new FMallocAnsi();
    #if WITH_MALLOC_STOMP
    	case EMemoryAllocatorToUse::Stomp:
    		return new FMallocStomp();
    #endif
    #if TBB_ALLOCATOR_ALLOWED
    	case EMemoryAllocatorToUse::TBB:
    		return new FMallocTBB();
    #endif
    #if MIMALLOC_ALLOCATOR_ALLOWED && PLATFORM_SUPPORTS_MIMALLOC
    	case EMemoryAllocatorToUse::Mimalloc:
    		return new FMallocMimalloc();
    #endif
    	case EMemoryAllocatorToUse::Binned2:
    		return new FMallocBinned2();
    #if PLATFORM_64BITS
    	case EMemoryAllocatorToUse::Binned3:
    		return new FMallocBinned3();
    #endif
    	default:	// intentional fall-through
    	case EMemoryAllocatorToUse::Binned:
    		return new FMallocBinned((uint32)(GetConstants().BinnedPageSize&MAX_uint32), (uint64)MAX_uint32 + 1);
    	}
    }
    

    由此可知,GMalloc是通過FMalloc的子類來操作記憶體。下表是不同的作業系統支援及預設的記憶體分配方式:

    作業系統 支援的記憶體分配方式 預設的記憶體分配方式
    Windows Ansi, Binned, Binned2, Binned3, TBB, Stomp, Mimalloc Binned
    Android Binned, Binned2, Binned3 Binned
    Apple(IOS, Mac) Ansi, Binned, Binned2, Binned3 Binned
    Unix Ansi, Binned, Binned2, Binned3, Stomp, Jemalloc Binned
    HoloLens Ansi, Binned, TBB Binned
  • FMemory:FMemory是UE的靜態工具類,它提供了很多靜態方法,用於操作記憶體,常見的api如下:

    // Engine\Source\Runtime\Core\Public\HAL\UnrealMemory.h
    
    struct CORE_API FMemory
    {
        // 直接呼叫c的記憶體分配和釋放介面.
    	static void* SystemMalloc(SIZE_T Size);
    	static void SystemFree(void* Ptr);
    	
        // 通過GMalloc物件操作記憶體
    	static void* Malloc(SIZE_T Count, uint32 Alignment = DEFAULT_ALIGNMENT);
    	static void* Realloc(void* Original, SIZE_T Count, uint32 Alignment = DEFAULT_ALIGNMENT);
    	static void Free(void* Original);
    	static void* MallocZeroed(SIZE_T Count, uint32 Alignment = DEFAULT_ALIGNMENT);
        
        // 記憶體輔助介面
        static void* Memmove( void* Dest, const void* Src, SIZE_T Count );
    	static int32 Memcmp( const void* Buf1, const void* Buf2, SIZE_T Count );
    	static void* Memset(void* Dest, uint8 Char, SIZE_T Count);
    	static void* Memzero(void* Dest, SIZE_T Count);
    	static void* Memcpy(void* Dest, const void* Src, SIZE_T Count);
    	static void* BigBlockMemcpy(void* Dest, const void* Src, SIZE_T Count);
    	static void* StreamingMemcpy(void* Dest, const void* Src, SIZE_T Count);
    	static void Memswap( void* Ptr1, void* Ptr2, SIZE_T Size );
        
        (......)
    };
    

    從上面程式碼可知,FMemory既支援GMalloc也支援C風格的記憶體操作。

  • new/delete操作符:除了部分類過載了new和delete操作符之外,其它全域性的new和delete使用的是以下宣告:

    // Engine\Source\Runtime\Core\Public\Modules\Boilerplate\ModuleBoilerplate.h
    
    #define REPLACEMENT_OPERATOR_NEW_AND_DELETE \
    	OPERATOR_NEW_MSVC_PRAGMA void* operator new  ( size_t Size                        ) OPERATOR_NEW_THROW_SPEC      { return FMemory::Malloc( Size ); } \
    	OPERATOR_NEW_MSVC_PRAGMA void* operator new[]( size_t Size                        ) OPERATOR_NEW_THROW_SPEC      { return FMemory::Malloc( Size ); } \
    	OPERATOR_NEW_MSVC_PRAGMA void* operator new  ( size_t Size, const std::nothrow_t& ) OPERATOR_NEW_NOTHROW_SPEC    { return FMemory::Malloc( Size ); } \
    	OPERATOR_NEW_MSVC_PRAGMA void* operator new[]( size_t Size, const std::nothrow_t& ) OPERATOR_NEW_NOTHROW_SPEC    { return FMemory::Malloc( Size ); } \
    	void operator delete  ( void* Ptr )                                                 OPERATOR_DELETE_THROW_SPEC   { FMemory::Free( Ptr ); } \
    	void operator delete[]( void* Ptr )                                                 OPERATOR_DELETE_THROW_SPEC   { FMemory::Free( Ptr ); } \
    	void operator delete  ( void* Ptr, const std::nothrow_t& )                          OPERATOR_DELETE_NOTHROW_SPEC { FMemory::Free( Ptr ); } \
    	void operator delete[]( void* Ptr, const std::nothrow_t& )                          OPERATOR_DELETE_NOTHROW_SPEC { FMemory::Free( Ptr ); } \
    	void operator delete  ( void* Ptr, size_t Size )                                    OPERATOR_DELETE_THROW_SPEC   { FMemory::Free( Ptr ); } \
    	void operator delete[]( void* Ptr, size_t Size )                                    OPERATOR_DELETE_THROW_SPEC   { FMemory::Free( Ptr ); } \
    	void operator delete  ( void* Ptr, size_t Size, const std::nothrow_t& )             OPERATOR_DELETE_NOTHROW_SPEC { FMemory::Free( Ptr ); } \
    	void operator delete[]( void* Ptr, size_t Size, const std::nothrow_t& )             OPERATOR_DELETE_NOTHROW_SPEC { FMemory::Free( Ptr ); }
    

    從原始碼可以看出,全域性的記憶體操作符也是通過呼叫FMemory完成記憶體操作。

  • 特定API:除了以上三種記憶體操作方式,UE還提供了各類建立、銷燬特定記憶體的介面,它們通常是成對出現,例如:

    struct FPooledVirtualMemoryAllocator
    {
        void* Allocate(SIZE_T Size);
    	void Free(void* Ptr, SIZE_T Size);
    };
    
    class CORE_API FAnsiAllocator
    {
        class CORE_API ForAnyElementType
    	{
        	void ResizeAllocation(SizeType PreviousNumElements, SizeType NumElements, SIZE_T NumBytesPerElement);
        };
    };
    
    class FVirtualAllocator
    {
        void* AllocateVirtualPages(uint32 NumPages, size_t AlignmentForCheck);
        void FreeVirtual(void* Ptr, uint32 NumPages);
    };
    
    class RENDERER_API FVirtualTextureAllocator
    {
        uint32 Alloc(FAllocatedVirtualTexture* VT );
    	void Free(FAllocatedVirtualTexture* VT );
    };
    
    template<SIZE_T RequiredAlignment> class TMemoryPool
    {
        void* Allocate(SIZE_T Size);
        void Free(void *Ptr, SIZE_T Size);
    };
    

從呼叫者的角度,多數情況下使用new/delete操作符和FMemory方式操作記憶體,直接申請系統記憶體的情況並不多見。

1.4.4 垃圾回收

垃圾回收的簡稱是GC(Garbage Collection),是一種將無效的資源以某種策略回收或重利用的機制,常用於遊戲引擎、虛擬機器、作業系統等。

1.4.4.1 GC演算法一覽

《垃圾回收的演算法與實現》一書中,提到的GC演算法有:

  • Mark-Sweep。即標記-清理演算法,演算法分兩個階段:

    第一階段是標記(Mark)階段,過程是遍歷根的活動物件列表,將所有活動物件指向的堆物件標記為TRUE

    第二階段是清理(Sweep)階段,過程是遍歷堆列表,將所有標記為FALSE的物件釋放到可分配堆,且重置活動物件的標記,以便下次執行標記行為。

  • BiBOP。全稱是Big Bag Of Pages,它的做法是將大小相近的物件整理成固定大小的塊進行管理,跟UE的FMallocBinned分配器的策略如出一轍。

  • Conservative GC。保守式GC,的特點是不能識別指標和非指標。由於在GC層面,單憑一個變數的記憶體值無法判斷它是否指標,由此引申出很多方法來判斷,需要付出一定的成本。與之相反的是準確式GC(Exact GC),它能通過標籤(tag)來明確標識是否指標。

  • Generational GC。分代垃圾回收,該方法在物件中引入年齡的概念,通過優先回收容易成為垃圾的物件,提高垃圾回收的效率。

  • Incremental GC。增量式垃圾回收,通過逐漸推進垃圾回收來控制mutator最大暫停時間的方法。

    增量式垃圾回收示意圖。

  • Reference Counting Immix。簡稱RC Immix演算法,即合併引用型GC演算法。目的是通過某種策略改善引用計數的行為,以達到提升GC吞吐量的目的。

UE的GC演算法主要是基於Mark-Sweep(標記-清理演算法),用於清理UObject物件。如同Mark-Sweep演算法,UE也有Root的概念,如果要防止某個物件(包括屬性、靜態變數)被GC清理,可藉助UObject的AddToRoot介面。

1.4.4.2 UE的GC

UE的GC模組的主體實現程式碼和解析如下:

// Engine\Source\Runtime\CoreUObject\Private\UObject\GarbageCollection.cpp

void CollectGarbage(EObjectFlags KeepFlags, bool bPerformFullPurge)
{
	// 獲得GC鎖, 防止GC過程被其它執行緒操作
	AcquireGCLock();

	// 執行GC過程
	CollectGarbageInternal(KeepFlags, bPerformFullPurge);

	// 釋放GC鎖, 以便其它執行緒可操作
	ReleaseGCLock();
}

// 真正執行GC操作。KeepFlags:排除清理的UObject標記,bPerformFullPurge:是否關閉增量更新
void CollectGarbageInternal(EObjectFlags KeepFlags, bool bPerformFullPurge)
{
	(......)

	{
		FGCScopeLock GCLock;
		
        // 確保上一次的增量清理垃圾已經完成, 或者乾脆來一次全量清理, 防止之前呼叫GC時留下了剩餘的垃圾.
		if (GObjIncrementalPurgeIsInProgress || GObjPurgeIsRequired)
		{
			IncrementalPurgeGarbage(false);
			FMemory::Trim();
		}

		// This can happen if someone disables clusters from the console (gc.CreateGCClusters)
		if (!GCreateGCClusters && GUObjectClusters.GetNumAllocatedClusters())
		{
			GUObjectClusters.DissolveClusters(true);
		}
        
        (......)

		// Fall back to single threaded GC if processor count is 1 or parallel GC is disabled
		// or detailed per class gc stats are enabled (not thread safe)
		// Temporarily forcing single-threaded GC in the editor until Modify() can be safely removed from HandleObjectReference.
		const bool bForceSingleThreadedGC = ShouldForceSingleThreadedGC();
		// Run with GC clustering code enabled only if clustering is enabled and there's actual allocated clusters
		const bool bWithClusters = !!GCreateGCClusters && GUObjectClusters.GetNumAllocatedClusters();

		{
			const double StartTime = FPlatformTime::Seconds();
			FRealtimeGC TagUsedRealtimeGC;
            // 執行可達性分析(即標記)
			TagUsedRealtimeGC.PerformReachabilityAnalysis(KeepFlags, bForceSingleThreadedGC, bWithClusters);
			UE_LOG(LogGarbage, Log, TEXT("%f ms for GC"), (FPlatformTime::Seconds() - StartTime) * 1000);
		}

		// Reconstruct clusters if needed
		if (GUObjectClusters.ClustersNeedDissolving())
		{
			const double StartTime = FPlatformTime::Seconds();
			GUObjectClusters.DissolveClusters();
			UE_LOG(LogGarbage, Log, TEXT("%f ms for dissolving GC clusters"), (FPlatformTime::Seconds() - StartTime) * 1000);
		}

		// Fire post-reachability analysis hooks
		FCoreUObjectDelegates::PostReachabilityAnalysis.Broadcast();
		
		{			
			FGCArrayPool::Get().ClearWeakReferences(bPerformFullPurge);

            // 收集不可達的物體
			GatherUnreachableObjects(bForceSingleThreadedGC);

			if (bPerformFullPurge || !GIncrementalBeginDestroyEnabled)
			{
                // 將不可達物體從雜湊表中刪除
				UnhashUnreachableObjects(/**bUseTimeLimit = */ false);
				FScopedCBDProfile::DumpProfile();
			}
		}

		// Set flag to indicate that we are relying on a purge to be performed.
		GObjPurgeIsRequired = true;

		// 全量清理垃圾
		if (bPerformFullPurge || GIsEditor)
		{
			IncrementalPurgeGarbage(false);
		}
		
        // 縮小UObject雜湊表
		if (bPerformFullPurge)
		{
			ShrinkUObjectHashTables();
		}

		// Destroy all pending delete linkers
		DeleteLoaders();

		// 釋放記憶體.
		FMemory::Trim();
	}

	// Route callbacks to verify GC assumptions
	FCoreUObjectDelegates::GetPostGarbageCollect().Broadcast();

	STAT_ADD_CUSTOMMESSAGE_NAME( STAT_NamedMarker, TEXT( "GarbageCollection - End" ) );
}

其中標記階段由FRealtimeGC::PerformReachabilityAnalysis的介面完成:

// Engine\Source\Runtime\CoreUObject\Private\UObject\GarbageCollection.cpp

class FRealtimeGC : public FGarbageCollectionTracer
{
	void PerformReachabilityAnalysis(EObjectFlags KeepFlags, bool bForceSingleThreaded, bool bWithClusters)
	{
		(......)

		/** Growing array of objects that require serialization */
		FGCArrayStruct* ArrayStruct = FGCArrayPool::Get().GetArrayStructFromPool();
		TArray<UObject*>& ObjectsToSerialize = ArrayStruct->ObjectsToSerialize;

		// 重置物體數量.
		GObjectCountDuringLastMarkPhase.Reset();

		// Make sure GC referencer object is checked for references to other objects even if it resides in permanent object pool
		if (FPlatformProperties::RequiresCookedData() && FGCObject::GGCObjectReferencer && GUObjectArray.IsDisregardForGC(FGCObject::GGCObjectReferencer))
		{
			ObjectsToSerialize.Add(FGCObject::GGCObjectReferencer);
		}

		{
			const double StartTime = FPlatformTime::Seconds();
            // 利用標記物體的函式給對應物體標上記號.
			(this->*MarkObjectsFunctions[GetGCFunctionIndex(!bForceSingleThreaded, bWithClusters)])(ObjectsToSerialize, KeepFlags);
			UE_LOG(LogGarbage, Verbose, TEXT("%f ms for Mark Phase (%d Objects To Serialize"), (FPlatformTime::Seconds() - StartTime) * 1000, ObjectsToSerialize.Num());
		}

		{
			const double StartTime = FPlatformTime::Seconds();
            // 執行物體的可達性分析.
			PerformReachabilityAnalysisOnObjects(ArrayStruct, bForceSingleThreaded, bWithClusters);
			UE_LOG(LogGarbage, Verbose, TEXT("%f ms for Reachability Analysis"), (FPlatformTime::Seconds() - StartTime) * 1000);
		}
        
		// Allowing external systems to add object roots. This can't be done through AddReferencedObjects
		// because it may require tracing objects (via FGarbageCollectionTracer) multiple times
		FCoreUObjectDelegates::TraceExternalRootsForReachabilityAnalysis.Broadcast(*this, KeepFlags, bForceSingleThreaded);

		FGCArrayPool::Get().ReturnToPool(ArrayStruct);

#if UE_BUILD_DEBUG
		FGCArrayPool::Get().CheckLeaks();
#endif
	}
};

上述的MarkObjectsFunctionsPerformReachabilityAnalysisOnObjects其實是對是否支援並行(Parallel)和群簇(Cluster)處理的組合型模板函式:

// Engine\Source\Runtime\CoreUObject\Private\UObject\GarbageCollection.cpp

class FRealtimeGC : public FGarbageCollectionTracer
{
	// 宣告
	MarkObjectsFn MarkObjectsFunctions[4];
	ReachabilityAnalysisFn ReachabilityAnalysisFunctions[4];
    
    // 初始化
	FRealtimeGC()
	{
		MarkObjectsFunctions[GetGCFunctionIndex(false, false)] = &FRealtimeGC::MarkObjectsAsUnreachable<false, false>;
		MarkObjectsFunctions[GetGCFunctionIndex(true, false)] = &FRealtimeGC::MarkObjectsAsUnreachable<true, false>;
		MarkObjectsFunctions[GetGCFunctionIndex(false, true)] = &FRealtimeGC::MarkObjectsAsUnreachable<false, true>;
		MarkObjectsFunctions[GetGCFunctionIndex(true, true)] = &FRealtimeGC::MarkObjectsAsUnreachable<true, true>;

		ReachabilityAnalysisFunctions[GetGCFunctionIndex(false, false)] = &FRealtimeGC::PerformReachabilityAnalysisOnObjectsInternal<false, false>;
		ReachabilityAnalysisFunctions[GetGCFunctionIndex(true, false)] = &FRealtimeGC::PerformReachabilityAnalysisOnObjectsInternal<true, false>;
		ReachabilityAnalysisFunctions[GetGCFunctionIndex(false, true)] = &FRealtimeGC::PerformReachabilityAnalysisOnObjectsInternal<false, true>;
		ReachabilityAnalysisFunctions[GetGCFunctionIndex(true, true)] = &FRealtimeGC::PerformReachabilityAnalysisOnObjectsInternal<true, true>;
	}
};  

從原始碼可知,UE的GC有以下特點:

  • 主要演算法是Mark-Sweep。但不同於傳統Mark-Sweep演算法只有2個步驟,UE的GC有3個步驟:

    1、索引可達物件。

    2、收集待清理物件。

    3、清理步驟2收集到的物件。

  • 在遊戲執行緒上對UObject進行清理。

  • 執行緒安全,支援多執行緒並行(Parallel)和群簇(Cluster)處理,以提升吞吐率。

  • 支援全量清理,編輯器模式下強制此模式;也支援增量清理,防止GC處理執行緒卡頓太久。

  • 可指定某些標記的物體不被清理。

實際上,UE的GC機制和原理遠比上面的表述要複雜得多,不過限於篇幅和主題,就不過多介紹了,有興趣的可以研讀UE原始碼或尋求參考文獻。

1.4.5 記憶體屏障

記憶體屏障(Memory Barrier)又被成為membar, memory fencefence instruction,它的出現是為了解決記憶體訪問的亂序問題以及CPU緩衝資料的不同步問題。

記憶體亂序問題可由編譯期或執行時產生,編譯期亂序是由於編譯器做了優化導致指令順序變更,執行時亂序常由多處理多執行緒的無序訪問產生。

1.4.5.1 編譯期記憶體屏障

對於編譯期記憶體亂序,舉個例子,假設有以下C++程式碼:

sum = a + b + c; 
print(sum);

由編譯器編譯後,彙編指令順序可能變成以下三種之一:

// 指令順序情況1
sum = a + b;
sum = sum + c;

// 指令順序情況2
sum = b + c; 
sum = a + sum; 

// 指令順序情況3
sum = a + c; 
sum = sum + b; 

以上情況對結果似乎都沒有影響,但對於以下的程式碼,將會產生不一樣的結果:

sum = a + b + sum; 
print(sum);

編譯後的質量如下情況:

// 指令順序情況1
sum = a + b;
sum = sum + sum;

// 指令順序情況2
sum = b + sum; 
sum = a + sum; 

// 指令順序情況3
sum = a + sum; 
sum = sum + b; 

很明顯,編譯成彙編指令後,三種情況都會得到不一樣的結果!!

為了防止編譯期的亂序問題,就需要在指令之間顯式地新增記憶體屏障,如:

sum = a + b;
__COMPILE_MEMORY_BARRIER__;
sum = sum + c;

上面的__COMPILE_MEMORY_BARRIER__在不同的編譯器有著不同的實現,部分編譯器實現如下所示:

// C11 / C++11
atomic_signal_fence(memory_order_acq_rel);

// Microsoft Visual C++
_ReadWriteBarrier();

// GCC
__sync_synchronize();

// GNU
asm volatile("" ::: "memory");
__asm__ __volatile__ ("" ::: "memory");

// Intel ICC
__memory_barrier();

除此之外,還有組合屏障(Combined barrier),即將不同型別的屏障組合成其它操作(如load, store, atomic increment, atomic compare and swap),所以不需要額外的記憶體屏障加在它們之前或之後。值得一提的是,組合屏障和CPU架構相關,在不同的CPU架構上會編譯成不同的指令,也依賴硬體記憶體順序保證(hardware memory ordering guarantee)。

1.4.5.2 執行時記憶體屏障

上面闡述了編譯期的記憶體亂序問題,下面將闡述執行時的記憶體亂序問題。

早期的處理器為有序處理器(In-order processors),這種處理器如果沒有編譯期亂序問題,則可以保證處理順序和程式設計師編寫的程式碼順序一致。

現代多核處理器橫行的時代,存在不少亂序處理器(Out-of-order processors),處理器真正執行指令的順序由可用的輸入資料決定,而非程式設計師編寫的順序,只有在所有更早請求執行的指令的執行結果被寫入暫存器堆後,指令執行的結果才被寫入暫存器堆(執行結果重排序(reorder),讓執行看起來是有序的)。

在亂序多處理器的架構中,如果沒有執行時記憶體屏障的機制,將會帶來很多意外的執行結果。下面舉個具體的例子。

假設有記憶體變數xf,它們的值都初始化為0,處理器#1和處理器#2都可以訪問它們,且處理器的執行指令分別如下所示:

處理器#1:

while (f == 0);
print(x);

處理器#2:

x = 42;
f = 1;

其中的一種情況可能是期望處理器#1輸出x的值是42。然而,實際並不如此。由於處理器#2可能是亂序執行,f = 1可能先於x = 42執行,此時處理器#1輸出的值是0而非42。同樣地,處理器#1可能先輸出x的值再執行while語句,也會得到非期望的結果。為了避免亂序執行產生的意外結果,可以在兩個處理器指令之間加入執行時的記憶體屏障:

處理器#1:

while (f == 0);
_RUNTIME_MEMORY_BARRIAR_; // 加入記憶體屏障, 保證f的值能夠讀取到其它處理器的最新值, 才會執行print(x)
print(x);

處理器#2:

x = 42;
_RUNTIME_MEMORY_BARRIAR_; // 加入記憶體屏障, 保證x對其它處理器可見, 才會執行f=1
f = 1;

上面的_RUNTIME_MEMORY_BARRIAR_是執行時記憶體屏障的代表,實際在不同硬體架構有著不同的實現,稍後會具體說到。

在硬體層面,存在L1、L2、L3等各級快取、Store Buffers以及多核多執行緒,為了讓記憶體有序,定義了很多狀態(如MESI)和訊息傳遞(MESI Messages),它們的之間的組合互動狀態數量多達十多種,且和CPU硬體架構相關,顯然如果直接讓程式設計師接觸和操控這些狀態,將會是一種災難。

MESI協議是一個基於失效的快取一致性協議,是支援回寫(write-back)快取的最常用協議。常用於多核CPU的快取記憶體和主記憶體的同步。

MESI協議的基礎狀態:Modified、Exclusive、Shared、Invalid。

MESI協議的訊息:Read、Read Response、Invalidate、Invalidate Acknowledge、Read Invalidate、Writeback。

MESI協議的基礎狀態的轉換如下圖:

每個基礎狀態之間都對應著不同的含義,不過這裡不展開闡述了。

與MESI類似的協議還有:Coherence protocol,MSI protocol,MOSI protocol,MOESI protocol,MESIF protocol,MERSI protocol等等。

更多關於MESI的詳情可參閱:

於是,聰明的人兒(如Doug Lea)簡化了這些狀態和訊息傳遞機制,並將它們組合成4種常用的組合屏障,以防止特定型別的記憶體排序來命名的。不同的cpu有特定的指令,這四種可以比較好的匹配真實cpu的指令,雖然也不是完全匹配。大多數時候,真實的cpu指令是多種型別的組合,以達到特定的效果。

讀屏障(Load Barrier)寫屏障(Store Barrier)應運而生。在指令前插入Load Barrier,可以讓快取記憶體中的資料失效,強制重新從主記憶體載入資料;若在指令後插入Store Barrier,可以讓快取記憶體中的最新資料寫入主記憶體,以便對其它處理器執行緒可見。

將Load Barrier和Store Barrier排列組合之後,可以形成4種指令:

  • LoadLoad:可以防止重新排序(reorder)導致的在屏障前後的讀取操作的亂序問題。

    加了LoadLoad屏障之後,即便CPU會亂序訪問,但也不會在LoadLoad屏障前後跳轉。

    應用舉例:

    if (IsValid)           // 載入並檢測IsValid
    {
        LOADLOAD_FENCE();  // LoadLoad屏障防止兩個載入之間的重新排序,在載入Value及後續讀取操作要讀取的資料被訪問前,保證IsValid及之前要讀取的資料被讀取完畢。
        return Value;      // 載入Value
    }
    
  • StoreStore:可以防止重排序導致的在屏障前後的寫入操作的亂序問題。

    應用舉例:

    Value = x;             // 寫入Value
    STORESTORE_FENCE();    // StoreStore屏障防止兩個寫入之間的重排序,在IsValid及後續寫入操作執行前,保證Value的寫入操作對其它處理器可見。
    IsValid = 1;           // 寫入IsValid
    
  • LoadStore:可以防止屏障前的載入操作和屏障後儲存操作的重排序。應用舉例:

    if (IsValid)            // 載入並檢測IsValid
    {
        LOADSTORE_FENCE();  // LoadStore屏障防止載入和寫入之間的重排序,在Value及後續寫入操作被刷出前,保證IsValid要讀取的資料被讀取完畢。
        Value = x;          // 寫入Value
    }
    
  • StoreLoad:可以防止屏障前的寫入操作和屏障後載入操作的重排序。在大多數CPU架構中,它是個萬能屏障,兼具其它三種記憶體屏障的功能,但開銷也是最大的。應用舉例:

    Value = x;          // 寫入Value
    STORELOAD_FENCE();  // 在IsValid及後續所有讀取操作執行前,保證Value的寫入對所有處理器可見。
    if (IsValid)        // 載入並檢測IsValid
    {
        return 1;
    }
    

對稱多處理(Symmetric Multiprocessing ,SMP)微型架構中,按照記憶體訪問一致性模型分類的話可分為:

  • Sequential consistency:順序一致,所有的讀取和寫入操作都是順序的。
  • Relaxed consistency:鬆散一致(或理解成部分一致性),Load之後的Load、Store之後的Store、Load之後的Store、Store之後的Load都會引起重新排序。
  • Weak consistency:弱一致,所有的讀取和寫入操作都可能引起重排序,除非有顯式的記憶體屏障。

下圖是部分常見CPU架構對在不同狀態的重排序情況表:

執行時的記憶體屏障在不同的硬體架構有著不同的實現,下面列出常見架構的實現:

// x86, x86-64
lfence (asm), void _mm_lfence(void) // 讀操作屏障
sfence (asm), void _mm_sfence(void) // 寫操作屏障
mfence (asm), void _mm_mfence(void) // 讀寫操作屏障

// ARMv7
dmb (asm) // Data Memory Barrier, 資料記憶體屏障
dsb (asm) // Data Synchronization Barrier, 資料同步屏障
isb (asm) // Instruction Synchronization Barrier, 指令同步屏障

// POWER
dcs (asm)

// PowerPC
sync (asm)

// MIPS
sync (asm)
 
// Itanium
mf (asm)

記憶體屏障是個廣闊的話題,限於篇幅和主題,無法完整地將它的技術和機制展示出來,但可以推薦幾篇延伸文章:

1.4.5.3 UE的記憶體屏障

UE的記憶體屏障都封裝在了FGenericPlatformMisc及其子類,下面貼出常見作業系統的實現:

struct FGenericPlatformMisc
{
    (......)
    
    /**
	 * Enforces strict memory load/store ordering across the memory barrier call.
	 */
    static void MemoryBarrier();
    
    (......)
};

// Windows
struct FWindowsPlatformMisc : public FGenericPlatformMisc
{
    (......)
    
    static void MemoryBarrier() 
    { 
        _mm_sfence(); 
    }
    
    (......)
};
#if WINDOWS_USE_FEATURE_PLATFORMMISC_CLASS
	typedef FWindowsPlatformMisc FPlatformMisc;
#endif

// Android
struct FAndroidMisc : public FGenericPlatformMisc
{
    (......)
    
    static void MemoryBarrier()
	{
		__sync_synchronize();
	}
    
    (......)
};
#if !PLATFORM_LUMIN
	typedef FAndroidMisc FPlatformMisc;
#endif

// Apple
struct FApplePlatformMisc : public FGenericPlatformMisc
{
    (......)
    
    static void MemoryBarrier()
	{
		__sync_synchronize();
	}
    
    (......)
};

// Linux
struct FLinuxPlatformMisc : public FGenericPlatformMisc
{
    (......)
    
    static void MemoryBarrier()
	{
		__sync_synchronize();
	}
    
    (......)
};
#if !PLATFORM_LUMIN
	typedef FLinuxPlatformMisc FPlatformMisc;
#endif

除了Windows用的是x86架構的指令外,其它系統都用的是GCC的記憶體屏障指令。令人感到詭異的是,Windows是執行時記憶體屏障,而其它平臺的似乎是編譯期記憶體屏障。這點筆者剛開始也是一臉懵逼,不過隨後在參考文獻Memory ordering找到了答案:

Compiler support for hardware memory barriers

Some compilers support builtins that emit hardware memory barrier instructions:

  • GCC, version 4.4.0 and later, has __sync_synchronize.
  • Since C11 and C++11 an atomic_thread_fence() command was added.
  • The Microsoft Visual C++ compiler has MemoryBarrier().
  • Sun Studio Compiler Suite has __machine_r_barrier, __machine_w_barrier and __machine_rw_barrier.

也就是說,部分編譯器的編譯期記憶體屏障也會觸發硬體(執行時)的記憶體屏障,其中就包含了GCC編譯器的__sync_synchronize

有了UE對系統平臺的多型封裝,對呼叫者而言,無需關注是哪個系統,無腦呼叫FPlatformMisc::MemoryBarrier()即可在程式碼中加入跨平臺的執行時記憶體屏障,示例程式碼如下:

// Engine\Source\Runtime\RenderCore\Private\RenderingThread.cpp

void RenderingThreadMain( FEvent* TaskGraphBoundSyncEvent )
{
	LLM_SCOPE(ELLMTag::RenderingThreadMemory);

	ENamedThreads::Type RenderThread = ENamedThreads::Type(ENamedThreads::ActualRenderingThread);

	ENamedThreads::SetRenderThread(RenderThread);
	ENamedThreads::SetRenderThread_Local(ENamedThreads::Type(ENamedThreads::ActualRenderingThread_Local));

	FTaskGraphInterface::Get().AttachToThread(RenderThread);
	
	// 加入系統記憶體屏障
	FPlatformMisc::MemoryBarrier();

	// Inform main thread that the render thread has been attached to the taskgraph and is ready to receive tasks
	if( TaskGraphBoundSyncEvent != NULL )
	{
		TaskGraphBoundSyncEvent->Trigger();
	}

	// set the thread back to real time mode
	FPlatformProcess::SetRealTimeMode();

#if STATS
	if (FThreadStats::WillEverCollectData())
	{
		FThreadStats::ExplicitFlush(); // flush the stats and set update the scope so we don't flush again until a frame update, this helps prevent fragmentation
	}
#endif

	FCoreDelegates::PostRenderingThreadCreated.Broadcast();
	check(GIsThreadedRendering);
	FTaskGraphInterface::Get().ProcessThreadUntilRequestReturn(RenderThread);
	
	// 加入系統記憶體屏障
	FPlatformMisc::MemoryBarrier();
	
	check(!GIsThreadedRendering);
	FCoreDelegates::PreRenderingThreadDestroyed.Broadcast();
	
#if STATS
	if (FThreadStats::WillEverCollectData())
	{
		FThreadStats::ExplicitFlush(); // Another explicit flush to clean up the ScopeCount established above for any stats lingering since the last frame
	}
#endif
	
	ENamedThreads::SetRenderThread(ENamedThreads::GameThread);
	ENamedThreads::SetRenderThread_Local(ENamedThreads::GameThread_Local);
	
	// 加入系統記憶體屏障
	FPlatformMisc::MemoryBarrier();
}

由此可看出,UE直接封裝和使用了執行時記憶體屏障,但並沒有封裝編譯期記憶體屏障。

除了系統的記憶體屏障,UE還封裝和使用了圖形API層的記憶體屏障:

// Direct3D / Metal
FPlatformMisc::MemoryBarrier();

// OpenGL
glMemoryBarrier(Barriers);

// Vulkan
typedef struct VkMemoryBarrier {
    (......)
} VkMemoryBarrier;

typedef struct VkBufferMemoryBarrier {
    (......)
} VkBufferMemoryBarrier;

typedef struct VkImageMemoryBarrier {
    (......)
} VkImageMemoryBarrier;

1.4.6 引擎啟動流程

學過Windows等作業系統程式設計的讀者應該都知道,對於每個應用程式,在不同的作業系統,有著不同的入口,比如Windows的程式入口是WinMain,而Linux是Main。下面將以Windows的PC平臺入口作為剖析流程,它的啟動程式碼如下:

// Engine\Source\Runtime\Launch\Private\Windows\LaunchWindows.cpp

int32 WINAPI WinMain( _In_ HINSTANCE hInInstance, _In_opt_ HINSTANCE hPrevInstance, _In_ char*, _In_ int32 nCmdShow )
{
	TRACE_BOOKMARK(TEXT("WinMain.Enter"));

	SetupWindowsEnvironment();

	int32 ErrorLevel			= 0;
	hInstance				= hInInstance;
	const TCHAR* CmdLine = ::GetCommandLineW();

    // 處理命令列
	if ( ProcessCommandLine() )
	{
		CmdLine = *GSavedCommandLine;
	}

	if ( FParse::Param( CmdLine, TEXT("unattended") ) )
	{
		SetErrorMode(SEM_FAILCRITICALERRORS | SEM_NOGPFAULTERRORBOX | SEM_NOOPENFILEERRORBOX);
	}

	(......)

    // 根據是否存在異常處理和錯誤等級, 進入不同的入口,但最終還是會進入GuardedMain函式.
#if UE_BUILD_DEBUG
	if( true && !GAlwaysReportCrash )
#else
	if( bNoExceptionHandler || (FPlatformMisc::IsDebuggerPresent() && !GAlwaysReportCrash ))
#endif
	{
		// 進入GuardedMain主入口
		ErrorLevel = GuardedMain( CmdLine );
	}
	else
	{
		(......)
        
 		{
			GIsGuarded = 1;
			// 進入GuardedMain主入口
			ErrorLevel = GuardedMainWrapper( CmdLine );
			GIsGuarded = 0;
		}
        
		(......)
	}

	// 退出程式
	FEngineLoop::AppExit();

	(......)

	return ErrorLevel;
}

以上的主分支都會最終進入GuardedMian介面,程式碼(節選)如下:

// Engine\Source\Runtime\Launch\Private\Launch.cpp

int32 GuardedMain( const TCHAR* CmdLine )
{
	(......)

	// 保證能夠呼叫EngineExit
	struct EngineLoopCleanupGuard 
	{ 
		~EngineLoopCleanupGuard()
		{
			EngineExit();
		}
	} CleanupGuard;

	(......)

    // 引擎預初始化
	int32 ErrorLevel = EnginePreInit( CmdLine );
	if ( ErrorLevel != 0 || IsEngineExitRequested() )
	{
		return ErrorLevel;
	}

	{
		(......)

#if WITH_EDITOR
		if (GIsEditor)
		{
            // 編輯器初始化
			ErrorLevel = EditorInit(GEngineLoop);
		}
		else
#endif
		{
            // 引擎(非編輯器)初始化
			ErrorLevel = EngineInit();
		}
	}

	(......)

	while( !IsEngineExitRequested() )
	{
        // 引擎幀更新
		EngineTick();
	}

#if WITH_EDITOR
	if( GIsEditor )
	{
        // 編輯器退出
		EditorExit();
	}
#endif
	return ErrorLevel;
}

不難看出,這段邏輯主要有4個步驟:引擎預初始化(EnginePreInit)、引擎初始化(EngineInit)、引擎幀更新(EngineTick)、引擎退出(EngineExit)。

1.4.6.1 引擎預初始化

UE引擎預初始化主要是在啟動頁面期間做的很多初始化和基礎核心相關模組的事情。

它的主程式碼如下:

// Engine\Source\Runtime\Launch\Private\Launch.cpp

int32 EnginePreInit( const TCHAR* CmdLine )
{
    // 呼叫GEngineLoop預初始化.
	int32 ErrorLevel = GEngineLoop.PreInit( CmdLine );

	return( ErrorLevel );
}


// Engine\Source\Runtime\Launch\Private\LaunchEngineLoop.cpp

int32 FEngineLoop::PreInit(const TCHAR* CmdLine)
{
    // 啟動小視窗的進度條
	const int32 rv1 = PreInitPreStartupScreen(CmdLine);
	if (rv1 != 0)
	{
		PreInitContext.Cleanup();
		return rv1;
	}

	const int32 rv2 = PreInitPostStartupScreen(CmdLine);
	if (rv2 != 0)
	{
		PreInitContext.Cleanup();
		return rv2;
	}

	return 0;
}

預初始化階段會初始化隨機種子,載入CoreUObject模組,啟動FTaskGraphInterface模組並將當前遊戲執行緒附加進去,之後載入UE的部分基礎核心模組(Engine、Renderer、SlateRHIRenderer、Landscape、TextureCompressor等),由LoadPreInitModules完成:

void FEngineLoop::LoadPreInitModules()
{
#if WITH_ENGINE
	FModuleManager::Get().LoadModule(TEXT("Engine"));
	FModuleManager::Get().LoadModule(TEXT("Renderer"));
	FModuleManager::Get().LoadModule(TEXT("AnimGraphRuntime"));

	FPlatformApplicationMisc::LoadPreInitModules();

#if !UE_SERVER
	if (!IsRunningDedicatedServer() )
	{
		if (!GUsingNullRHI)
		{
			// This needs to be loaded before InitializeShaderTypes is called
			FModuleManager::Get().LoadModuleChecked<ISlateRHIRendererModule>("SlateRHIRenderer");
		}
	}
#endif

	FModuleManager::Get().LoadModule(TEXT("Landscape"));
	FModuleManager::Get().LoadModule(TEXT("RenderCore"));

#if WITH_EDITORONLY_DATA
	FModuleManager::Get().LoadModule(TEXT("TextureCompressor"));
#endif

#endif // WITH_ENGINE

#if (WITH_EDITOR && !(UE_BUILD_SHIPPING || UE_BUILD_TEST))
	FModuleManager::Get().LoadModule(TEXT("AudioEditor"));
	FModuleManager::Get().LoadModule(TEXT("AnimationModifiers"));
#endif
}

隨後處理的是配置Log、載入進度資訊、記憶體分配器的TLS(執行緒區域性範圍)快取、設定部分全域性狀態、處理工作目錄、初始化部分基礎核心模組(FModuleManager、IFileManager、FPlatformFileManager等)。還有比較重要的一點:處理遊戲執行緒,將當前執行WinMain的執行緒設定成遊戲執行緒(主執行緒)並記錄執行緒ID。此段程式碼如下:

int32 FEngineLoop::PreInitPreStartupScreen(const TCHAR* CmdLine)
{
	(......)
    
	GGameThreadId = FPlatformTLS::GetCurrentThreadId();
	GIsGameThreadIdInitialized = true;

	FPlatformProcess::SetThreadAffinityMask(FPlatformAffinity::GetMainGameMask());
	FPlatformProcess::SetupGameThread();
    
    (......)
}

接著設定Shader原始碼目錄對映,處理網路令牌(Token),初始化部分基礎模組(FCsvProfiler、AppLifetimeEventCapture、FTracingProfiler)以及App,隨後會根據平臺是否支援多執行緒來建立執行緒池和指定數量的執行緒:

int32 FEngineLoop::PreInitPreStartupScreen(const TCHAR* CmdLine)
{
	(......)
    
	if (FPlatformProcess::SupportsMultithreading())
	{
		{
			TRACE_THREAD_GROUP_SCOPE("IOThreadPool");
			SCOPED_BOOT_TIMING("GIOThreadPool->Create");
			GIOThreadPool = FQueuedThreadPool::Allocate();
			int32 NumThreadsInThreadPool = FPlatformMisc::NumberOfIOWorkerThreadsToSpawn();
			if (FPlatformProperties::IsServerOnly())
			{
				NumThreadsInThreadPool = 2;
			}
			verify(GIOThreadPool->Create(NumThreadsInThreadPool, 96 * 1024, TPri_AboveNormal));
		}
	}
    
    (......)
}

然後初始化或處理UGameUserSettings、Scalability、渲染執行緒(如果開啟)、FConfigCacheIni、FPlatformMemory、遊戲物理、RHI、RenderUtils、FShaderCodeLibrary、ShaderHashCache。

在預初始化後期階段,引擎會處理SlateRenderer、IProjectManager、IInstallBundleManager、MoviePlayer、PIE預覽裝置、引擎預設材質等模組。

1.4.6.2 引擎初始化

引擎初始化要分編輯器和非編輯器兩種模式,非編輯器執行的是FEngineLoop::Init,編輯器執行的是EditorInit+FEngineLoop::Init,這裡只分析非編輯器執行的初始化邏輯。

引擎初始化的流程由FEngineLoop::Init完成,它的主要流程如下:

  • 根據配置檔案建立對應的遊戲引擎例項並儲存到GEngine, 後面會大量使用到GEngine例項。
  • 根據是否支援多執行緒判斷是否需要建立EngineService例項。
  • 執行GEngine->Start()
  • 載入Media、AutomationWorker、AutomationController、ProfilerClient、SequenceRecorder、SequenceRecorderSections模組。
  • 開啟執行緒心跳FThreadHeartBeat。
  • 註冊外部分析器FExternalProfiler。

1.4.6.3 引擎幀更新

引擎初始化的流程由FEngineLoop::Tick完成,它的主要流程如下:

  • 開啟執行緒和執行緒鉤子心跳。

  • 更新渲染模組可每幀更新的物體(FTickableObjectRenderThread例項)。

  • 分析器(FExternalProfiler)幀同步。

  • 執行控制檯的回撥介面。

  • 重新整理渲染命令(FlushRenderingCommands)。如果未開啟單獨的渲染執行緒,會在遊戲執行緒執行渲染指令,隨後呼叫ImmediateFlush確保命令佇列提交繪製。在末尾會新增渲染柵欄(FRenderCommandFence)。

    // Engine\Source\Runtime\RenderCore\Private\RenderingThread.cpp
    
    void FlushRenderingCommands(bool bFlushDeferredDeletes)
    {
    	(......)
    
    	if (!GIsThreadedRendering
    		&& !FTaskGraphInterface::Get().IsThreadProcessingTasks(ENamedThreads::GameThread)
    		&& !FTaskGraphInterface::Get().IsThreadProcessingTasks(ENamedThreads::GameThread_Local))
    	{
    		FTaskGraphInterface::Get().ProcessThreadUntilIdle(ENamedThreads::GameThread);
    		FTaskGraphInterface::Get().ProcessThreadUntilIdle(ENamedThreads::GameThread_Local);
    	}
    
    	ENQUEUE_RENDER_COMMAND(FlushPendingDeleteRHIResourcesCmd)(
    		[bFlushDeferredDeletes](FRHICommandListImmediate& RHICmdList)
    	{
    		RHICmdList.ImmediateFlush(
    			bFlushDeferredDeletes ?
    			EImmediateFlushType::FlushRHIThreadFlushResourcesFlushDeferredDeletes :
    			EImmediateFlushType::FlushRHIThreadFlushResources);
    	});
    
    	AdvanceFrameRenderPrerequisite();
    
    	FPendingCleanupObjects* PendingCleanupObjects = GetPendingCleanupObjects();
    
    	FRenderCommandFence Fence;
    	Fence.BeginFence();
    	Fence.Wait();
        
        (......)
    }
    
  • 觸發OnBeginFrame事件。

  • 重新整理執行緒日誌。

  • 用GEngine重新整理時間和處理最大幀率。

  • 遍歷所有WorlContext的當前World,更新World內的場景的PrimitiveSceneInfo。這裡直接貼程式碼可能更容易理解:

    for (const FWorldContext& Context : GEngine->GetWorldContexts())
    {
        UWorld* CurrentWorld = Context.World();
        if (CurrentWorld)
        {
            FSceneInterface* Scene = CurrentWorld->Scene;
            ENQUEUE_RENDER_COMMAND(UpdateScenePrimitives)(
                [Scene](FRHICommandListImmediate& RHICmdList)
                {
                    Scene->UpdateAllPrimitiveSceneInfos(RHICmdList);
                });
        }
    }
    
  • 處理RHI幀開頭。

  • 呼叫所有場景的StartFrame

  • 處理效能分析和資料統計。

  • 處理渲染執行緒的每幀任務。

  • 處理世界標尺縮放(WorldToMetersScale)。

  • 更新活動平臺的檔案。

  • 處理Slate模組輸入。

  • GEngine的Tick事件。這個是主要的幀更新,很多邏輯都將在此處理。下面是UGameEngine::Tick的主要流程:

    • 如果時間間隔夠了,重新整理Log。
    • 清理已經關閉的遊戲檢視(Viewport)。
    • 更新子系統(subsystem)。
    • 更新FEngineAnalytics、FStudioAnalytics模組。
    • (如果開啟Chaos)更新ChaosModule。
    • 處理WorldTravel的幀更新。
    • 處理所有World的幀更新。
    • 更新天空光元件(USkyLightComponent)和反射球元件(UReflectionCaptureComponent)。說明這兩個元件比較特殊,需要hard code。
    • 處理玩家物件(ULocalPlayer)。
    • 處理關卡流式載入。
    • 更新所有可更新的物體。此處更新的是FTickableGameObject。
    • 更新GameViewport。
    • 處理視窗模式下的視窗。
    • 繪製Viewport。
    • 更新IStreamingManager、FAudioDeviceManager模組。
    • 更新渲染相關的GRenderingRealtimeClock、GRenderTargetPool、FRDGBuilder等模組。
  • 處理GShaderCompilingManager的非同步編譯結果。

  • 處理GDistanceFieldAsyncQueue(距離場非同步佇列)的非同步任務。

  • 並行處理Slate相關的任務邏輯。

  • 處理可複製屬性(ReplicatedProperties)。

  • 利用FTaskGraphInterface處理儲存於ConcurrentTask的並行任務。

  • 等待渲染佇列未解決的渲染任務。可能理解的不準確,還是貼程式碼:

    ENQUEUE_RENDER_COMMAND(WaitForOutstandingTasksOnly_for_DelaySceneRenderCompletion)(
        [](FRHICommandList& RHICmdList)
        {
            QUICK_SCOPE_CYCLE_COUNTER(STAT_DelaySceneRenderCompletion_TaskWait);
            FRHICommandListExecutor::GetImmediateCommandList().ImmediateFlush(EImmediateFlushType::WaitForOutstandingTasksOnly);
        });
    
  • 更新AutomationWorker模組。

  • 更新RHI模組。

  • 處理幀計數(GFrameCounter)和總的幀更新時間(TotalTickTime)。

  • 收集需要在下一幀被清理的物體。

  • 處理幀結束同步事件(FFrameEndSync)。

  • 更新Ticker、FThreadManager和GEngine的TickDeferredCommands。

  • 在遊戲執行緒觸發OnEndFrame。

  • 在渲染模組觸發EndFrame事件。

1.4.6.4 引擎退出

引擎退出時,如果是非編輯器模式,直接返回ErrorLevel值;如果是編輯器模式會執行EditorExit邏輯。它的主要工作是儲存Log,關閉和釋放各個引擎模組:

// Engine\Source\Editor\UnrealEd\Private\UnrealEdGlobals.cpp

void EditorExit()
{
	TRACE_CPUPROFILER_EVENT_SCOPE(EditorExit);

	GLevelEditorModeTools().SetDefaultMode(FBuiltinEditorModes::EM_Default);
	GLevelEditorModeTools().DeactivateAllModes(); // this also activates the default mode

	// Save out any config settings for the editor so they don't get lost
	GEditor->SaveConfig();
	GLevelEditorModeTools().SaveConfig();

	// Clean up the actor folders singleton
	FActorFolders::Cleanup();

	// Save out default file directories
	FEditorDirectories::Get().SaveLastDirectories();

	// Allow the game thread to finish processing any latent tasks.
	// Some editor functions may queue tasks that need to be run before the editor is finished.
	FTaskGraphInterface::Get().ProcessThreadUntilIdle(ENamedThreads::GameThread);

	// Cleanup the misc editor
	FUnrealEdMisc::Get().OnExit();

	if( GLogConsole )
	{
		GLogConsole->Show( false );
	}

	delete GDebugToolExec;
	GDebugToolExec = NULL;
}

 

特別說明

  • 感謝所有參考文獻的作者,部分圖片來自參考文獻和網路,侵刪。
  • 本系列文章為筆者原創,只發表在部落格園上,歡迎分享本文連結,但未經同意,不允許轉載
  • 系列文章,未完待續,完整目錄請戳內容綱目
  • 系列文章,未完待續,完整目錄請戳內容綱目
  • 系列文章,未完待續,完整目錄請戳內容綱目

 

參考文獻

相關文章