剖析虚幻渲染体系(01)- 综述和基础

 

 

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;
}

 

特别说明

  • 感谢所有参考文献的作者,部分图片来自参考文献和网络,侵删。
  • 本系列文章为笔者原创,只发表在博客园上,欢迎分享本文链接,但未经同意,不允许转载
  • 系列文章,未完待续,完整目录请戳内容纲目
  • 系列文章,未完待续,完整目录请戳内容纲目
  • 系列文章,未完待续,完整目录请戳内容纲目

 

参考文献

posted @ 2020-10-26 11:34  0向往0  阅读(596)  评论(4编辑  收藏