博客 阅读时间 14 分钟

深入虚幻引擎 4.5 源代码

分享本文
Inside the Unreal Engine 4.5 source code

虚幻引擎(Unreal Engine)是由Epic Games开发的一款游戏引擎,最早在 1998 年的第一人称射击游戏《虚幻》(Unreal)中亮相。尽管最初主要为第一人称射击游戏开发,但它已被成功用于多种其他游戏类型,包括潜行类、MMORPG 和其他 RPG。

它的代码用 C++ 编写,如今被许多游戏开发者使用。它的源代码可以在GitHub上获取,并且对学生免费。许多精彩的游戏都是使用这个引擎开发的;它能够产出如下图所示的极其逼真的渲染效果。

488-unreal-engine-4

幕后究竟执行了什么样的源代码,才能产出如此逼真的渲染效果?

深入这个强大游戏引擎的内部,探究它的设计与实现,是一件非常有意思的事。C++ 开发者可以从它的代码库中学到许多最佳实践。

让我们使用CppDependCQLinq来探索它的源代码,检测其开发团队的一些设计与实现选择。

1. 命名空间

虚幻引擎广泛使用命名空间,主要有三个原因:

  • 许多命名空间只包含枚举,下面的 CQLinq 查询就返回了那些只包含枚举的命名空间。
unreal2

在大型项目中,无法保证两个不同的枚举不会使用相同的名字。这个问题在 C++11 中通过enum class得到了解决,它隐式地把枚举值限定在枚举名的作用域内。

  • 匿名命名空间:没有名字的命名空间,可以避免使用全局 static 变量。您创建的“匿名”命名空间只在创建它的文件内可访问。下面是使用到的所有匿名命名空间的列表:
unreal3
  • 对代码库进行模块化:让我们搜索所有其他命名空间,即既非匿名命名空间、也非只包含枚举的命名空间:
unreal6

命名空间是模块化应用程序的好方案;虚幻引擎定义了超过 250 个命名空间来保持其模块性,这让代码更可读、更易维护。

2. 使用的范式:

C++ 不仅仅是一门面向对象的语言。正如 Bjarne Stroustrup 所指出的,“C++ 是一门多范式语言。”它支持许多不同的程序风格(范式),而面向对象编程只是其中之一。其他的还有过程式编程和泛型编程等。

2.1 过程式范式

2.1.1 全局函数

让我们搜索虚幻引擎源代码中定义的所有全局函数:

unreal7

我们可以把这些函数分为三类:

1 - 工具函数:例如,其中 6,344 个是 Z_Construct_UXXX 函数,用于创建引擎所需的实例。

unreal8

2 - 运算符:定义了许多运算符,如这条 CQLinq 查询的结果所示:

unreal9

几乎所有种类的运算符都在虚幻引擎源代码中得到了实现。

3 - 与引擎逻辑相关的函数:实现了许多包含引擎逻辑的全局函数。也许这类函数可以按类别分组——作为类的静态方法,或者归入命名空间。

2.1.2 静态全局函数

除非确实需要从另一个源文件调用某个全局函数,否则最好把它声明为 static。

unreal10

许多全局函数被声明为 static;如前所述,其他全局函数则定义在匿名命名空间内。

2.1.3 可以改为 static 的全局函数

未导出的全局函数:既没有定义在匿名命名空间中,也没有被其定义文件之外的任何方法使用——这些函数是重构为 static 函数的良好候选。

unreal65

可以看到,一些全局函数是可以改为 static 的候选。

2.2 面向对象范式

2.2.1 继承

在面向对象编程(OOP)中,继承是在对象之间建立“是一个”(Is-a)关系的一种方式。它常被误用为复用现有代码的手段,而这并不是好做法,因为为实现复用而使用继承会导致紧耦合。代码的复用应通过组合来实现(组合优于继承)。让我们搜索所有至少有一个基类的类:

unreal13

为了更好地了解该查询涉及的类,我们可以使用度量视图。

在度量视图中,代码库以树状图表示。树状图是一种用嵌套矩形展示树形结构数据的方法。它使用的树结构就是常见的代码层次结构:

  • 项目包含命名空间。
  • 命名空间包含类型。
  • 类型包含方法和字段。

树状图视图为展示 CQLinq 查询结果提供了一种实用的方式;蓝色矩形代表查询结果,让我们可以直观地看到查询涉及的类型。

unreal12

可以看到,继承在虚幻引擎源代码中被广泛使用。

多重继承:让我们搜索继承自多个具体类的类。

unreal15

多重继承使用得并不多;只有少数类继承自多个类。

2.2.2 虚方法

让我们搜索虚幻引擎源代码中定义的所有虚方法:

unreal19

许多方法是虚方法,其中一些是纯虚方法:

unreal21

与过程式范式一样,OOP 范式在虚幻引擎源代码中也被广泛使用。那泛型编程范式呢?

2.3 泛型编程

C++ 通过模板提供了表达泛型编程思想的独特能力。模板提供了一种参数化多态,可以表达泛型算法和数据结构。C++ 模板的实例化机制确保:当泛型算法或数据结构被使用时,会创建一个针对该特定用途量身定制、完全优化的特化版本。

2.3.1 泛型类型

让我们搜索引擎源代码中定义的所有泛型类型:

unreal23

只有少数类型被定义为泛型。让我们搜索泛型方法:

unreal26

超过 40,000 个方法是泛型的;它们占已实现方法的 25% 以上。

总而言之,虚幻引擎源代码混合使用了三种范式。

3. 用 POD 定义数据模型

在面向对象编程中,简单旧数据(POD)是一种仅表示为字段值(实例变量)被动集合的数据结构,不使用面向对象的特性。在计算机科学中,这被称为被动数据结构。

让我们搜索虚幻引擎源代码中的 POD 类型。

unreal28

超过 2,000 个类型被定义为 POD 类型;其中许多用于定义引擎的数据模型。

4. 四人帮(GoF)设计模式

设计模式是软件工程中的一个概念,描述软件设计中常见问题的复用性解决方案。四人帮(GoF)模式是最流行的。让我们来发掘虚幻引擎源代码中用到的一些模式。

4.1 单例模式

单例是最流行、使用最多的模式。下面是源代码中定义的一些单例类:

unreal29

TThreadSingleton 是单例的一个特殊版本:每个线程只创建一个实例。调用它的 Get() 方法是线程安全的。

4.2 工厂模式

使用工厂有助于隔离实例化逻辑并增强内聚性;下面是源代码中定义的工厂列表:

unreal30

下面是其中的抽象工厂列表:

unreal31

4.3 观察者模式

观察者模式是一种软件设计模式:一个对象维护一份依赖者(称为观察者)列表,并在状态发生任何变化时自动通知它们。

源代码中实现了一些观察者;FAIMessageObserver 就是其中之一。

下面是一张依赖图,展示了对此观察者的 OnMessage 方法的调用:

unreal70

4.4 命令模式

命令模式是一种行为型设计模式:用一个对象来表示并封装稍后调用某个方法所需的全部信息。

与命令模式总是相伴的四个术语是:命令(command)、接收者(receiver)、调用者(invoker)和客户端(client)。命令对象持有一个接收者对象,并以该接收者类特有的方式调用接收者的某个方法。

例如,下面是所有继承自 IAutomationLatentCommand 的命令:

unreal33

5. 耦合与内聚

5.1 耦合

低耦合是可取的,因为应用程序某一处的变更,需要整个应用程序中其他地方的改动也会更少。从长远来看,这可以节省与修改应用程序和添加新功能相关的大量时间、精力和成本。

低耦合可以通过使用抽象类,或者使用泛型类型和泛型方法来实现。

让我们搜索虚幻引擎源代码中定义的所有抽象类:

unreal34

只有少数类型被声明为抽象。低耦合更多是通过使用泛型类型和泛型方法来保持的。

例如,下面是至少使用了一个泛型方法的方法:

unreal27

可以看到,许多方法使用了泛型方法;低耦合由函数模板参数保持。确实,这些参数的真实类型可以改变,而被调用方法的源代码无需改动。

内聚

单一职责原则指出,一个类不应有超过一个变更理由。这样的类被称为内聚的。较高的 LCOM 值通常表明类的内聚性较差。LCOM 度量有好几种:LCOM 的取值范围是 [0-1];LCOM HS(HS 代表 Henderson-Sellers)的取值范围是 [0-2]。LCOM HS 值高于 1 就应视为警讯。

LCOM = 1 – (sum(MF)/M*F) LCOM HS = (M – sum(MF)/F)(M-1)

其中:

  • M 是类中方法的数量(静态方法和实例方法都计入;还包括构造函数、属性的 getter/setter 以及事件的 add/remove 方法)。
  • F 是类中实例字段的数量。
  • MF 是访问某个特定实例字段的类方法数量。
  • Sum(MF) 是类中所有实例字段的 MF 之和。

这些公式背后的基本思想可以这样表述:如果一个类的所有方法都使用它的所有实例字段,那么这个类就是完全内聚的,也就是说 sum(MF)=M*F,此时 LCOM = 0 且 LCOMHS = 0。

LCOMHS 值高于 1 就应视为警讯。

unreal36

只有少数类型被认为是非内聚的。

6. 不可变性、纯性与副作用

6.1 不可变类型

基本上,如果一个对象在创建之后其状态不再改变,它就是不可变的。相应地,如果一个类的实例都是不可变的,这个类就是不可变的。

支持使用不可变对象有一个重要的理由:它能极大简化并发编程。想一想——为什么编写正确的多线程代码如此困难?因为很难同步多个线程对资源(对象或其他操作系统资源)的访问。为什么同步这些访问很困难?因为很难保证多个读写操作之间不会发生竞态条件。

不可变类的另一个好处是,它们绝不可能违反 LSP(里氏替换原则);下面是引自其维基页面的 LSP 定义:

Liskov 的行为子类型概念定义了针对可变对象的可替换性概念;也就是说,如果 S 是 T 的子类型,那么程序中 T 类型的对象可以被 S 类型的对象替换,而不改变该程序的任何期望性质(如正确性)。

下面是源代码中定义的不可变类型的列表:

unreal38

6.2 纯性与副作用

不可变类型的主要好处在于它们消除了副作用。关于这一点,Wes Dyer说得再好不过,我直接引用他的话:

我们都知道,一般来说使用全局变量不是好主意。这基本上是暴露副作用的极端情况(全局作用域)。许多不使用全局变量的程序员没有意识到,同样的原则在更小的尺度上也适用于字段、属性、参数和变量:除非有充分的理由,否则不要改变它们。(……)提高一个单元可靠性的一种方法是消除副作用。这让单元的组合与集成容易得多、也健壮得多。由于它们没有副作用,无论在什么环境下,它们的行为都始终如一。这被称为引用透明性。把您的函数/方法写成没有副作用的——即它们是纯函数,也就是说它们不改变对象——会让推理程序的正确性变得更容易。

下面是所有无副作用方法的列表。

unreal41

超过 125,000 个方法是纯方法。

7. 实现质量

7.1 过大的方法

代码行数很多的方法不易于维护和理解。让我们搜索超过 60 行的方法。

unreal44

虚幻引擎源代码包含超过 150,000 个方法,因此只有不到 1% 可以被认为是过大的。

7.2 参数很多的方法

unreal45

参数超过 8 个的方法很少;其中大多数是泛型方法,以避免定义变参函数,TCString::Snprintf 系列方法就是这种情况。

7.3 局部变量很多的方法

unreal46

不到 1% 的方法有大量局部变量。

7.4 过于复杂的方法

有许多度量可用于检测复杂函数;代码行数(NBLinesOfCode)、参数数量和局部变量数量是基本的几个。

还有其他一些有趣的度量可用于检测复杂函数:

  • 圈复杂度是一种流行的过程式软件度量,等于一个过程中可能发生的决策数量。
  • 嵌套深度是定义在方法上的度量,表示方法体内嵌套最深的作用域的深度。
  • 最大嵌套循环数等于函数中循环嵌套的最大层数。

这些度量可容忍的最大值主要取决于团队的选择;没有标准值。

让我们在虚幻引擎代码库中搜索可以被认为是复杂的方法。

unreal49

只有 1.5% 的方法是为降低复杂度而重构的候选。

7.5 Halstead 复杂度

Halstead 复杂度度量是 Maurice Howard Halstead 于 1977 年提出的软件度量。Halstead 认为,软件的度量应当反映算法在不同语言中的实现或表达,但独立于其在特定平台上的执行。因此,这些度量是从代码中静态计算出来的。

Halstead 提出了许多度量;以 TimeToImplement 为例,它表示编写一个方法所需的时间(以秒计)。

unreal50

有 1,748 个方法的实现需要超过一小时。

8. RTTI

RTTI(运行时类型信息)指系统在运行时(而非编译期)报告对象动态类型并提供该类型信息的能力。然而,RTTI 在 C++ 社区中一直存在争议。许多 C++ 开发者选择不使用这个机制。

虚幻引擎开发团队又是什么情况呢?

unreal60

没有任何方法使用 dynamic_cast 关键字;虚幻引擎团队选择不使用 RTTI 机制。

9. 异常

异常处理是另一个存在争议的 C++ 特性。许多知名的开源 C++ 项目都不使用它。

让我们搜索虚幻引擎源代码中是否有任何地方抛出了异常。

unreal62

一些方法中抛出了异常;以 RaiseException 为例:

unreal61

正如其注释中所说明的,异常可能为头文件工具(header tool)而生成,但在正常的运行时代码中,他们不支持异常处理。

10. 一些统计数据

10.1 最热门的类型

了解一个项目中使用最多的类型很有意思;确实,这些类型必须经过良好的设计、实现和测试。对它们的任何变更都可能影响整个项目。

我们可以使用TypesUsingMe度量来找到它们:

unreal71

不过,还有另一个有趣的度量可用于搜索热门类型:TypeRank。

TypeRank 值是通过将Google PageRank算法应用于类型依赖关系图计算出来的。计算时应用了中心为 0.15 的位似变换,使 TypeRank 的平均值为 1。

TypeRank 高的类型应当被更仔细地测试,因为这类类型中的缺陷很可能造成更严重的后果。

下面是按 TypeRank 度量得出的所有热门类型的结果:

unreal52

10.2 最热门的方法

unreal54

10.3 调用许多其他方法的方法

了解使用了大量其他方法的方法很有意思;这可能揭示这些方法中的设计问题,在某些情况下需要重构,以提高它们的可读性和可维护性。

unreal57
分享本文