Unreal Engine 4 优化教程(三)
此为Unreal Engine 4 优化教程的第三部分,旨在帮助开发人员利用 Unreal Engine* 4 (UE4) 提升游戏性能。本教程对UE4引擎内部和外部使用的一系列工具以及面向编辑器的最佳实践加以概述,还提供了有助于提高帧速率和项目稳定性的脚本。
脚本优化
禁用完全透明对象
即使完全透明的游戏对象也会用到渲染绘制调用。为避免这些调用浪费,可设置引擎停止对它们的渲染。
如要使用蓝图完成该操作,UE4 需有多个系统共同协作。
材质参数集合
首先,创建一个材质参数集合 (MPC)。这些资源储存了可被游戏中任何材质引用的标量参数和向量参数,可用于在游戏中修改这些材质以产生动态效果。
可通过在 Create Advanced Asset > Materials & Textures 菜单中选中它来创建 MPC。

在 MPC 中,可创建、命名和设置默认的标量参数和向量参数值。对于该优化,我们使用 Opacity(透明度)标量参数并用其控制材质的透明度。

材质
接下去,我们需要可利用 MPC 的材质。在该材质中,创建一个名为 Collection Parameter 的 节点。通过该节点选择 MPC 以及其将使用的参数。

创建节点后,拖曳其 Return Pin(回位梢)以使用该参数的值。
图 35: 在材质中设置 Collection Parameter。
蓝图脚本部分
在创建 MPC 和材质后,我们可通过蓝图设置和获取 MPC 的值。可通过 Get/Set Scalar Parameter Value 和 Get/Set Vector Parameter Value 调用和更改这些值。在这些节点中,选择要使用的集合 (MPC) 以及该集合内的参数名称。
在本示例中,我们将 Opacity 标量值设置为游戏时间的正弦值,介于 1 到 -1 之间。
图 36: 设置和使用标量参数并在函数中使用它的值。
如要设置是否渲染对象,我们可创建名为 Set Visible Opacity 的新函数,以 MPC 的 Opacity 参数值和一个静态网格组件为输入,输出则为对象是否可见的 Boolean 值。
然后我们进行是否大于近似零值的检测,在本示例中为 0.05。检测 0 值也可以,但接近零时玩家无法再看到对象,因此我们可在值接近零时将其关闭。不将标量参数准确设置为零有助于在出现浮点错误时提供缓冲,确保其被关闭(例如,将其设置为 0.0001)。
之后,运行分支,其中 True 条件设置对象可见度为 true,False 条件设置为 false。
图 37: 设置 Visible Opacity 函数。
Tick、剔除和时间
如果场景中的蓝图使用 Event Tick,则即使对象不再出现在屏幕中,脚本也会运行。正常情况下这没有问题,但场景中每帧调用的蓝图越少,其运行得越快。
可使用本优化的部分示例如下:
- 在玩家不看时无需进行的东西
- 根据游戏时间运行的进程
- 在玩家不在时无需做任何事情的非玩家角色 (NPC)
作为一种简单的解决方案,我们可在 Event Tick 开始时添加“是否最近进行渲染”(Was Recently Rendered) 检查。这样,我们无需再关联自定义时间和侦听器来进行“tick”的开关,系统仍可独立于场景中的其他 Actor。
图 38: 利用剔除系统控制 Event Tick 的内容。
按该方法,如果我们有根据游戏时间运行的进程,如每秒钟变暗和变亮的按钮发光材质,我们可使用下文中的方法。
图 39: 在进行渲染时,材质集合的 Emissive Value 设置为时间的绝对正弦值。
图中所示是通过绝对正弦值加 1 (产生介于 1 和 2 之间的正弦波)来检测经过的游戏时间。
此举的优势是不管玩家什么时候看这个按钮,即使是他们旋转或长期注视时,因为值设置为游戏时间的正弦值,按钮始终看起来按该曲线准确对时。
这在模组 (modulo) 中也能发挥作用,虽然图形看上去有点不同。
之后可在 Event Tick 中调用该检测。如果 Actor 在每帧中需要完成多项重要任务,其可在渲染检测前进行。蓝图中 tick 所调用节点数量的任何减少都能实现一定程度的优化。
图 40: 使用剔除控制蓝图的视觉组成部分。
限制蓝图消耗的另一方法是减慢其速度并只允许每一时间间隔只“tick”一次。这可通过 Set Actor Tick Interval 节点实现,由此可通过脚本设置所需时间。
图 41: Tick 时间间隔切换。
此外还可在蓝图的 Details 选项卡设置 Tick Interval。这使蓝图按秒数时间进行 tick 时也能进行设置。

例如,这在计秒数应用中很有用。
图 43: 设置秒计蓝图仅每秒 tick 一次。
以下示例减少了平均毫秒数,可帮助我们了解该优化的作用。

这里我们有进行 0 到 10000 计数的 ForLoop,我们将整数计数设置到当前的 ForLoop 计数中。这一蓝图会消耗很多,效率非常低,导致我们的场景运行时间长达 53.49 毫秒。

如果我们进入分析器 (Profiler) 就知道为什么了。这一简单但消耗众多资源的蓝图每次 tick 需耗费 43 毫秒。
图 46: 分析器中可看到的示例每帧 tick 所需的消耗。
但,如果我们仅每秒钟 tick 该蓝图一次,则大部分时间它只需 0 毫秒。如果查看蓝图三个 tick 周期的平均时间(单击并拖曳 Graph View 的一块区域),我们会发现其所使用的平均时间是 0.716 毫秒。
图 47: 分析器中可看到的示例每秒 tick 一次所需的消耗。
可再看一个更为常见的示例,如果在以 60 fps 运行的场景中有一个运行时间为 1.4 毫秒的蓝图,其需要 84 毫秒的处理时间。但是,如果我们可减少其 tick 时间,它就能减少蓝图的总处理时间。
大量移动、ForLoop 和多线程
让多个网格同时移动,这是个非常棒的想法,可成为游戏的真正视觉风格卖点。但所需的处理资源会给 CPU 造成巨大压力,并反过来影响 FPS 。幸运的是,借助多线程功能和 UE4 的 Worker 线程处理,我们能够将这一大量的处理分散到多个蓝图,从而实现优化。
在本章节,我们将使用以下蓝图脚本,随修改后的正弦曲线上下移动由 1600 个实例化球面网格组成的集合。
以下是构建栅格的简单构造脚本。仅需将实例化静态网格组件添加到 Actor 中,在 Details 选项卡选择其使用的网格,而后将这些节点添加到其构造中。
图 48: 构建简单栅格的构造脚本。
创建栅格后,将这一蓝图脚本添加到 Event Graph 中。
关于 Update Instance Transform 节点,需要注意以下问题。在修改任何示例的变换时,除非将 Mark Render State Dirty 标记为 True,否则无法看到变化。但是,这是一个耗费甚多的操作,因为它会进入实例的每一网格并将之标记为“dirty”。为节省操作消耗,特别是当节点在单一 tick 中运行多次时,在该蓝图结束时再更新网格。在以下脚本中,我们仅在处于 ForLoop 的 Last Index 中,且当索引值等于 Grid Size 减去 1 时,才将 Mark Render State Dirty 标记为“true”。
图 49:实例化静态网格的动态移动蓝图。
利用我们的 Actor 蓝图和栅格创建构造以及动态移动事件,我们可放置多个不同的变体,目标都是一次性显示 1600 个网格。

在运行场景时,我们会看到栅格各片上下移动。

但是,栅格的分解会影响场景的运行速度。
从以上图表可知,分为 1600 片的实例化静态网格格栅(每片 1 个网格)(甚至是抵消了使用实例的意义)和单一一片 1600 网格的栅格运行最慢,其余的性能则在 19 和 20 毫秒之间。
单片运行最慢的原因在于运行 1600 个蓝图的时间是 16.86 毫秒,每个蓝图平均仅 0.0105 毫秒。但是,虽然每一蓝图所需时间很短,如此多数量累加会拖累整个系统。可进行优化的唯一地方是减少每 tick 所运行的蓝图数量。其他变慢原因包括大量单个网格所导致绘制调用以及网格变换命令数量的增加。
图的另一极是第二慢的单一一片 1600 网格格栅。这一网格在绘制调用上非常高效,因为整个栅格只有一次绘制调用,但运行蓝图时必须在每次 tick 更新所有 1600 个网格,导致需 19.63 毫秒的时间进行处理。
比较其它三个集合的处理时间,我们可发现分解这些大量移动 Actor 的好处,因为这样可实现更短的脚本时间并利用引擎内的多线程功能。因为 UE4 利用多线程,它将蓝图分散到多个 Worker 线程中,可通过有效利用所有 CPU 内核来加速求值。
如果查看蓝图运行时间的简单分解以及它们在 Worker 线程间的分配,我们可得到下图。
数据结构
对于任何程序而言,使用正确的数据类型都是重中之重,而游戏开发与任何其他软件开发一样,也是如此。在 UE4 中利用蓝图进行编程时,对于作为主要容器的模板数组并未给出数据结构。它们可利用 UE4 提供的函数和节点手动创建。
典型用途
关于在游戏开发中为何以及如何使用数据结构,可考虑射击 (Shmup) 风格的游戏。射击游戏的主要机制之一就是在屏幕上向接近的敌人射出成千上万的子弹。虽然可以生成每一颗子弹、然后销毁它们,这会要求引擎进行大量的碎片收集,并导致帧率变慢或丢失。为应付该问题,开发人员可考虑子弹的孵化池(对象集合均被放置到在游戏开始时将处理的数组和 List 中),按需启用和禁用它们,使引擎仅需一次创建所有子弹。
使用这些孵化池的一种常见方法是抓取未启用数组/List 中的第一颗子弹,使之进入出发位置、启用子弹、然后在子弹飞出屏幕或射入敌人后再禁用它。该方法的问题是脚本的运行时间,或者说Big O 问题。因为是在对象集合中迭代寻找下一被禁用对象,如果集合有 5000 个对象(举例说明),找到一个对象需要多次迭代。这种类型的函数会出现 O(n) 时间,其中 n 是集合中对象的数量。
当 O(n) 未糟糕到算法无法进行时,我们越能够接近 O(1),即无关集合大小的固定消耗,脚本和游戏的效率越高。如孵化池要实现这一目标,我们可利用 Queue(队列)数据结构。和现实生活中的队列一样,这一数据结构获取集合中的第一个对象、使用它并在之后移除它,而后继续队列直至每一对象从前端出队。
通过对孵化池使用队列,我们可获取我们集合的前端对象、启用它、而后在集合中弹出(移除)它并立即将之推到集合后端;从而在我们的脚本中实现高效的循环并将运行时间减少到 O(1)。我们还可在该循环中添加启用的检查。如果本将弹出的对象已启用,脚本会取代该对象,转而孵化新的对象、启用它、而后将之推到队列后端,由此增加集合的大小而不会降低运行时间的效率。
队列
以下各图说明了如何在蓝图中执行队列、使用函数帮助维持代码的简洁和重用性。
弹出
图 52: 蓝图中执行的列队弹出,包含返回操作。
后推

空缺

大小

前端

后端
图 57: 蓝图中执行的队列后端。
插入
图 58:蓝图中执行的队列插入,包含位置检查。
交换
图 59: 蓝图中执行的队列交换,包含位置检查
堆栈
以下各图说明了如何在蓝图中执行堆栈、使用函数帮助维持代码的简洁和重用性。
弹出

后推

空缺

大小

后端

插入
图 65: 蓝图中执行的堆栈插入,包含位置检查。