平衡蓝图与C++

描述如何融合蓝图/C++游戏,以及在这个过程中可能需要作出的决定。

Choose your operating system:

Windows

macOS

Linux

前置主题

为了理解并使用本文中的内容,请确保您已掌握以下主题:

在为游戏进行整体技术设计时,一个主要问题是,哪些部分应使用蓝图实现,哪些部分应使用C++实现?本文档旨在讨论如何回答这类问题,并提供有关构建可靠的数据驱动型游戏系统的建议。本文档面向已经阅读过基本编程文档并有兴趣了解更多信息的程序员或技术设计师。构建游戏的"正确方法"并不唯一,本文档旨在帮助你提出正确的问题。

Gameplay逻辑和数据

从广义上讲,游戏中的内容可以分为 逻辑(Logic) 数据(Data) 。Gameplay逻辑是某些游戏部分所遵循的 指令(instructions) 结构(structure) ,而Gameplay数据则由逻辑使用并描述游戏是什么。这种划分有时很明显,在屏幕上绘制角色的C++代码显然是基于逻辑的,而角色的实际外观明显基于数据。但是,在实践中,这些类别相互融合,使得项目复杂化,因此了解两者之间的区别和你有哪些选择很重要。

在虚幻引擎4(UE4)中,实现Gameplay逻辑的方法有多种:

  • C++ 类: 使用C++定义变量和函数,并实现基本的Gameplay逻辑。

  • 蓝图类: 逻辑可以使用 蓝图(Blueprints) 中的 事件图表 实现,也可以使用从这些图表中调用的 函数(Functions) 实现。另外,也可以添加其他变量。

  • 自定义系统: 许多系统和游戏都有"微语言",描述Gameplay逻辑的某些方面。例如,UE4 材质编辑器 Sequencer轨道 AI行为树 都是用于存储Gameplay逻辑的自定义系统。

对于数据,你有更多选择:

  • C++ 类: 原生类构造函数设置默认值并支持数据继承。数据也可以硬编码到函数局部变量中,但很难跟踪。

  • 配置文件: Ini文件和 控制台变量 支持覆盖在C++构造函数中声明的数据,也可以直接查询。

  • 蓝图类: 蓝图类默认值与C++类构造函数的作用相同,支持数据继承。数据也可以在函数局部变量或引脚文字值中安全地设置。

  • 数据资产: 对于无法实例化且不需要数据继承的对象,独立数据资产比蓝图默认值更易于使用。

  • 表: 数据可以作为 数据表 、曲线表导入,也可以在运行时读取。

  • 放置实例: 数据可以存储在关卡或其他资产内设置的蓝图或C++类实例中,并会覆盖类默认值。

  • 自定义系统: 与逻辑一样,可以使用多种自定义方法来存储数据。
    保存游戏: 运行时保存游戏文件可用于覆盖或修改上述数据类型。

通常,这些列表中较下的派生选项将覆盖并扩展其上的基础选项。因此,基础系统很难访问和使用扩展系统定义的内容。例如,从C++类访问蓝图类添加的变量非常困难,我们不建议这么做。为了避免这类问题,应该在最基础的级别定义函数和变量,一般这样才可以进行访问。对于逻辑,完全在基础级实现是可行的,或者你也可以在基础级保留存根函数并在派生程度较大的级别将其覆盖。

数据规则更为复杂,且更加特定于系统,因为存在更多可能性,更深层的继承也更为普遍。你需要在定义变量的级别为变量设置默认值,任何派生程度较大的级别都可以覆盖这些值。另外一种常见做法是,编写逻辑以将数据从一个对象复制到另一个对象,具体取决于自定义规则。下面将介绍数据架构的一些常见问题。

C++与蓝图

从以上列出内容可以看出,C++或蓝图类都可用于存储逻辑和数据。由于具有这种灵活性,多数游戏系统选用这两种中的一种(或以某种形式结合使用)。因为每个游戏和开发团队都是独一无二的,所以在决定使用什么时没有所谓的"正确选择",但这里有一些通用指导原则可以帮助决定使用C++还是蓝图:

C++类的优势

  • 更快的运行时性能 :通常,由于下面描述的原因,C++逻辑比蓝图逻辑运行速度快得多。

  • 显式设计 :当使用C++公开变量或函数时,可以更精确地控制要公开的内容,因此能够保护特定的函数/变量并为类构建正式的"API"。这样可避免创建过大且难以遵循的蓝图。

  • 更广泛的访问 :使用C++定义的函数和变量(并且正确公开),可以从所有其他系统访问,非常适合在不同系统之间传递信息。此外,与蓝图相比,C++可以更充分地利用引擎功能。

  • 更强的数据控制 :在加载和保存数据时,C++可以使用更具体的功能。这能够让你以高度自定义的方式处理版本变更和序列化。

  • 网络复制 :蓝图中支持的复制功能非常简单,专门设计用于较小的游戏或独特的一次性Actor。如果要严格控制复制带宽或时序,需要使用C++。

  • 更容易的数学运算 :在蓝图中进行复杂的数学运算可能非常困难,有时还会很慢,所以可考虑使用C++完成数学运算量较大的操作。

  • 更方便对比/合并 :C++代码和数据(以及配置和可能的自定义解决方案)以文本形式存储,因而可更方便地处理多个分支。

蓝图类的优势

  • 更快创建 :对于多数人而言,创建新的蓝图类并添加变量和函数比使用C++完成此类任务更快,因此在蓝图中对全新系统进行原型设计通常速度会更快。

  • 更快迭代 :尽管可以使用热加载,但在编辑器中修改蓝图逻辑和预览要比重新编译游戏快得多。对于成熟系统和新系统都是如此,因此所有"可调整"值都应尽可能存储在资产中。

  • 流程更直观 :使用C++直观地展现"游戏流程"可能很复杂,因此通常最好在蓝图(或在专为此设计的行为树等自定义系统中)中进行。与使用C++代理相比,因为可以延迟和异步运行节点,所以可以更容易地遵循流程。

  • 灵活编辑 :没有接受过特定技术培训的设计师和美术也可以创建和编辑蓝图,因此蓝图非常适合需要除工程师以外的人员修改的资产。

  • 更简单的数据使用 :因为使用蓝图类存储数据比使用C++类更简单、更安全,所以蓝图适用于频繁混合数据和逻辑的类。

从蓝图转换为C++

使用蓝图更易于创建和迭代,因此通常在蓝图中构建原型,然后将部分或全部功能转至C++中。当处于"重构点"时,通常要采用这种做法。处于"重构点"时,你已经证明了系统的基本功能,并希望对功能进行"加强",以便其他人可以无中断地使用。此时,你需要决定哪些类、函数和变量应转换为C++,哪些应保留在蓝图中。在做出此类决定之前,建议了解将这些内容重构为C++的流程。

通常,第一步是创建一组蓝图类将继承的"基本"C++类。为游戏创建基础原生类后,需要将原型蓝图的父级更改到新的原生类。完成此操作后,可以开始将蓝图类中的变量和函数移动到原生C++代码类中。如果原生类中的变量或函数与蓝图变量的类型和名称相同,你需要将蓝图的外部引用改为指向原生基类。例如,在处理动作RPG样本时,我们将此代码块添加到 DefaultEngine.ini 文件中:

[CoreRedirects]
+ClassRedirects=(OldName="BP_Item_C", NewName="/Script/ActionRPG.RPGItem", OverrideClassName="/Script/CoreUObject.Class")
+ClassRedirects=(OldName="BP_Item_Potion_C", NewName="/Script/ActionRPG.RPGPotionItem", OverrideClassName="/Script/CoreUObject.Class")
+ClassRedirects=(OldName="BP_Item_Skill_C", NewName="/Script/ActionRPG.RPGSkillItem", OverrideClassName="/Script/CoreUObject.Class")
+ClassRedirects=(OldName="BP_Item_Weapon_C", NewName="/Script/ActionRPG.RPGWeaponItem", OverrideClassName="/Script/CoreUObject.Class")

上面的代码块使用 Core Redirects 系统将所有对 Blueprint BP Item C 的引用转换为引用新的原生类RPGItem。 OverrideClassName 选项必须选中,因为系统需要知道现在是 UClass ,而不是 UBlueprintGeneratedClass 。在初步重定父级和修补后,需要修复任何延迟的蓝图编译问题,然后重新保存游戏中的所有蓝图。目标是以 蓝图警告完成重构,以便添加新问题后方便跟踪。一切正常运行后,你就可以删除在修复过程中添加的所有 CoreRedirects 并清理ini文件。

性能问题

使用C++而不使用蓝图的一个重要原因是性能问题。但是,在许多情况下,蓝图性能在实践中是不成问题的。总体而言,主要区别在于执行蓝图中的每个单独节点比执行一行C++代码要慢,但是一旦在一个节点内执行,会像从C++调用一样快。例如,如果蓝图类有一些性能成本较低的顶层节点,而调用的是性能成本较高的物理追踪(Physics Trace)函数,那么将该类转换为C++不会显著提高性能。但是,如果蓝图类有很多紧凑的循环或许多扩展到数百个节点的嵌套宏,那么应该考虑将其转换为C++。Tick函数是一个最重要的性能问题。执行蓝图Tick可能比执行原生Tick慢得多,因此对于任何具有许多实例的类都应完全避免Tick。相反,你应使用计时器或代理,让蓝图类仅在需要时才工作。

确定蓝图类是否会导致性能问题的最佳方法是使用 分析工具 。要了解项目中的性能情况,首先要建立一个情景,其中蓝图类会严重影响性能(例如生成一群敌人),然后使用分析工具采集一个配置文件。使用分析工具,可以深入了解 游戏线程Tick(Game Thread Tick) 并展开树状列表,直至找到个别蓝图类为止(它可能将同一类的所有实例组合在一起,因此请注意这一点)。在蓝图类中,你可以看到花费时间的蓝图函数,然后展开它。如果大部分时间都花费在 Self 节点中,那么会由于蓝图消耗而导致性能下降。但是,如果大部分时间都花费在嵌套于函数内的其他原生事件中,那么蓝图消耗不会影响性能。

蓝图原生化 可以缓解许多此类问题,但它也的确有一些缺点。首先,它会改变烘焙工作流,从而减慢烘焙游戏的迭代速度。此外,原生化蓝图的运行时逻辑与普通蓝图的运行时逻辑不同,因此你可能会看到不同的错误或行为,具体取决于游戏的特征。原生化支持大多数蓝图功能,但可能不支持某些模糊不清的功能。最后,性能改进不一定像将其转换为C++时一样显著。原生化可能无法解决所有性能问题,但应将其作为可能的性能问题解决方案进行研究。

架构说明

在结合使用蓝图和C++构建游戏时,随着游戏变得越加庞大和复杂,你会遇到各种挑战。在开始推进项目时,请注意以下几点:

  • 避免转换为性能成本较高的蓝图 :每当从 BP_B 转换为蓝图类 BP_A (或在函数或其他蓝图上声明其为变量类型)时,它都会在该蓝图上创建加载依赖关系。这样,如果 BP_A 引用了四个大的静态网格体和20个音效,那么每次加载 BP_B 时,即使转换失败,也必须加载四个大的静态网格体和20个音效。这是定义重要函数和变量的原生基类或最小蓝图基类之所以重要的主要原因之一。然后,应该将性能消耗较大的蓝图作为子类。

  • 避免循环引用蓝图 :由于头文件的关系,在C++中循环引用(一个类引用另一个类,而后者又引用了前一个类)是没有问题的。但是,过多的循环蓝图引用会使编辑器加载和编译时间变长。与上面类似,可通过转换为C++类或性能成本较低的蓝图基类来改进,而不是转换(或变量引用)为性能成本较高的子蓝图。

  • 避免从C++类引用资产 :我们可以使用 FObjectFinder FClassFinder 从C++构造函数引用资产,但应尽量避免。以这种方式引用的资产将在项目启动时加载,因此如果实际上不需要引用,将导致加载时间和内存方面的问题。此外,从构造函数引用的资产无法轻易删除或重命名。通常,建议创建一些 " 游戏 数据 " 资产或蓝图类型,并使用资产管理器或配置文件加载它们,而不是从C++引用特定的静态网格体。

  • 避免使用字符串引用资产 :为了避免从C++类加载资产时出现问题,可使用C++函数中的 LoadObject 等函数在磁盘上手动加载特定资产。但是,烘焙程序完全不会跟踪这些参考,因此可能会导致封装游戏出现问题。相反,你应该在C++类中使用 FSoftObjectPath TSoftObjectPtr 类型,从ini或蓝图类设置这些类型,然后根据需要或通过异步加载进行加载。

  • 注意用户结构体和枚举值 :C++和蓝图都可以使用C++中定义的枚举值和结构体,但是用户结构体/枚举值不能在C++中使用,也不能按照保存游戏部分中的描述手动修复。你可能希望随着时间的推移将更多的游戏逻辑移至C++,因此我们建议在C++中实现关键的枚举值和结构体。基本上,如果不止一两个蓝图使用某些项,这些项应该在原生C++中实现。

  • 考虑网络架构 :游戏的特定网络架构将对构建类的方式产生重大影响。一般来说,在构建原型时并不是心中已有成型的网络,所以当开始重构内容使其变得"真实"时,需要考虑哪些Actor将要复制什么数据。为了使复制数据的流程更为顺畅,你可能需要做出决定来增加迭代难度。

  • 考虑异步加载 :随着游戏日益庞大,需要按需加载资产,而不是在游戏加载时预先加载所有内容。一旦实现这一点,需要开始对内容进行转换,以便使用 软性引用(Soft references) PrimaryAssetIds ,而不是 硬性引用(Hard references) AssetManager 提供了多种函数,可以更方便地异步加载资产,还可以公开提供低级关卡函数的 StreamableManager

欢迎帮助改进虚幻引擎文档!请告诉我们该如何更好地为您服务。
填写问卷调查
取消