大规模内容的性能保障:虚幻引擎4.26中的Sequencer

Epic Games资深程序员Andrew Rodham
由于实时影片内容的复杂性、规模与保真度还在继续挑战虚幻引擎的品质包封,我们得以用批判的眼光评估虚幻引擎的电影工具Sequencer的运行时功能,确定有优化潜力的领域。

UE 4.26中,Sequencer已经通过内部架构的改造获得大幅度优化,在用于大规模影片和并发UI动画时能够实现远超以往的高性能。在这篇技术博客中,我们将探讨使用数据导线的设计原则对Sequencer数据结构与运行时逻辑的再组织如何带来众多收益,包括大大提高处理大型数据集的优化潜力,以及实现更出色的第三方扩展性。这些优化为今后通过Sequencer制作更具交互性与动态的内容铺平了道路。
 

在一个序列中有什么?

有些人可能还不知道,Sequencer是虚幻引擎的非线性编辑工具。通过它可以制作过场动画、游戏内事件、UMG窗口控件、离线渲染影片等的关键帧动画。它主要由三种元素组成:对象绑定、轨迹与分段。
 
图1--Sequencer中各种元素的位置
 
分段通常包含关键帧数据和其他属性,定义其时间轴中每个变换、属性或动画的所需状态。当在游戏中运行一个序列时,引擎的作用就是读取所有这些轨道与分段,并将正确的状态和属性应用到从游戏场景中解析得到的所需对象。

在4.26之前,这些轨道和分段都有各自的运行时实例,其中包含在特定时间进行评估并将属性应用于解析对象所需的数据和逻辑。这些实例是使用传统的对象导向范式设计的,而且要通过虚拟API访问。虽然这种做法在轨道数量很少的情况下能产生可接受的性能,但是随着轨道数量及其相关复杂性增加,它的伸缩性就会显得很差。不仅如此,并行运行大量动画或序列还会产生为每个实例初始化管线的高级性能成本。

通过研究各种用例的CPU分析数据可以明显看出,由于固有的虚拟函数调用开销和糟糕的缓存局部性,优化努力的收益会随着规模增大而递减。此外,变换轨道之类的轨道也受累于不断增加的各种职责,例如混合、不完全动画变换、相对变换原点、附件、组件速度,等等。

很显然,为了实现必要的优化,使这样的内容也能在游戏机和/或移动硬件上和其他所有游戏系统一样实时运行,就必须进行更系统的重新设计。
 

为速度而设计

在考虑为Sequencer运行时设计新的基础时,我们主要着眼于下列设计目标:

可伸缩性
如果使用新的运行时,应该能够编写包含成百上千个轨道或序列的内容,并且将这些内容作为一个整体来优化求值逻辑。这包括:
  • 分配和组织数据,使性能不会随着活动Sequencer轨道的增加而快速恶化。
  • 能够完全删除已知不再需要的逻辑和分支,不必在每一帧都为它们付出成本。没有代码就是最快的代码。这条原则应该适用于Sequencer求值代码的所有方面,而不仅限于最高级。
  • 求值逻辑应该能够直接、高效且不受阻碍地批量访问必要数据,而不必通过复杂或低效的抽象与内存交互。

并发性
写入求值逻辑应该很简单,而且天然就能安全而高效地扩展到多个核心,包括上游/下游依赖性的表达定义(例如,并行地对所有曲线求值)。由于具备仅设置一次管线的综合优点,这不仅能让许多轻量级的小型动画受益,也有益于非常大的序列。

可扩展性
应该可以在内置功能的基础上构建逻辑,而不必重新实现核心系统。添加与核心系统交互的上游或下游功能应该是可以合理实现的。这包括:
  • 在管线中的任何位置,当前帧的所有数据都应该是透明且可改变的。
  • 可靠的依赖性管理。
这些设计目标,再加上Sequencer本身的各种问题,使得采用数据导向的设计原则成为自然而然的选择,理由如下:
  • 大部分Sequencer数据是同质的,可以循序布局。
  • 每种数据变换的逻辑通常具有非常高的独立性,与情境无关(即运行函数f(x)来得到曲线)。
  • 控制和数据流是非常线性的,没有循环或递归的依赖性(也就是说,没有一种逻辑会要求重新计算已经计算出的数值)。
  • 只有初始设置和最终属性设置器才有线程限制。

在4.26版中,我们重新设计了Sequencer的基础运行时,通过一个基于实体-组件-系统模式的求值框架,以高效利用缓存且透明的方式对求值数据进行布局。轨道实例(在内部被称为模板)不再是求值所必需的(不过旧的运行时仍然会和新的系统一起执行)。每一种轨道的数据和逻辑都已解耦:现在实体代表与源轨道数据相关的组件部分,系统代表与特定组件或组件组合相关的一种逻辑数据变换。现在系统会批量操作与其查询匹配的所有数据。例如,这种做法可以实现所有变换属性的缓存高效应用,或者只需要一次虚拟函数调用就能对所有浮点信道求值,无论动画中有多少变换属性,缓存未命中现象都非常少。

因此从4.26版开始,对于迁移后轨道类型的大型内容,Sequencer的求值开销将会显著降低。 

此外,我们还通过这种方法将许多序列或窗口控件动画的求值数据合并在一起,使得整套运行动画有了更大的优化潜力。因此与以往相比,现在可以让更多窗口控件动画一起运行,还可以在必要时将多个独立的动画或序列混合在一起。

管线示例 
我们来看一个对所有变换轨道求值的示例管线。Sequencer中的一条变换轨道可以包含一到九个关键帧信道,对应其复合浮点——X/Y/Z轴位置、Roll/Pitch/Yaw旋转和X/Y/Z比例。要将变换应用到对象,我们必须先在当前时间对这些曲线求值,然后使用结果调用“USceneComponent::SetRelativeTransform”。在4.26版之前,系统会将信道数据复制到一个模板对象,其中包含最多九个信道和用于对这些信道求值并将其应用到对象的逻辑:
Figure 2
图2--4.26版之前的变换轨道求值
 
在这种设计中,虚拟函数调用(动态调度)次数会随着轨道的数量线性增加,同时缓存未命中的可能性也会线性增加。

在新的框架中,当根据轨道的对象解析轨迹时,将会创建一个变换实体,以及指向复合浮点信道的指针,每个信道的求值结果的浮点,一个指向“USceneComponent::SetRelativeTransform”的函数指针,以及一个表明该实体是变换属性的标记。Figure 3
图3--从4.26版起的新变换轨道求值
新的方法实现了显著的改进,原因如下:
  • 更好地分离了关切、数据和指令缓存局部性,在成规模的情况下,速度明显加快; 
  • 虚拟函数调用次数随活动系统类型的数量而非个别轨道的数量增减; 
  • 缓存未命中的次数与组件数据的组合数量成正比,而与整体数据大小无关; 
  • 这些增益有可能对整个数据集都实现,优化不必局限于每个轨道。 
  • 现在可以在不对逻辑做重大修改的情况下,使部分管线并发运行。
例如,以上示例中的“对浮点信道求值”系统能够并行地对当前帧的所有浮点信道求值,不论它们的动画是什么属性,也不论它们要如何组合或应用。

不仅如此,引入中间数据变换的操作(例如将多个变换混合成一个对象)也变得更简单、更分隔化了。

在4.26版之前,此功能是在一个专用类中实现的,它会存储和处理所有属性类型的所有输入/输出。由于小分配优化和动态调度的成本,这会产生存储器和CPU开销。它还要求以一种理解混合代码路径的方式表达属性的应用,这就进一步提高了复杂性,导致通用(无混合)代码路径的降级。
Figure 4
图4--4.26版之前的变换轨道求值混合
新的轨道允许这种混合代码在其自身的系统中实时运行,对输入和输出都分配唯一的ID。这样一来,不仅能通过并发性和数据模型的效率增益实现更快的运算,而且在不需要混合时,该系统就完全不存在,从而使运行时开销降为零。 
Figure 5
图5--从4.26版起的变换轨道求值混合

内存布局

我们再稍微深入地了解一下Sequencer内存中的求值数据。我们设计了各种数据布局方法的原型,最后选择了下列数据模型,因为它在内存大小、缓存效率、再分配成本和并发访问之间做了很好的趋势。 

实体被分组为多个批次,称为实体分配,每个分配包含至少一个实体。关键是,一个分配中的所有实体都必须有相同数量和类型的组件。在一个特定实体中添加或删除组件时,该组件就会迁移到一个不同的(或新的)分配。分配的大小是动态设置的,并且有保留的容量,因此不必调用malloc就能添加新的实体(只要初始化组件数据即可)。
Figure 6
图6--FEntityAllocation和FComponentHeader分配示例
例如,下图显示了一个分配,它含有Float Result 0、Float Channel 0Eval Time组件,以及示例数据和与这些信道的求值相关的逻辑。
Figure 7
图7--来自实体分配的示例数据
你可以看到,在一个分配中,相同类型的所有组件都是循序布局的。假设一个分配包含组件A、B和C,那么就会产生AAA..A、BBB..B、CCC..C的布局,每种类型都对应一条缓存线。通过以这种方式组织数据,我们可以控制实体数据的位置而不影响其实体ID,还可以控制对每个组件数组的读写访问(目前仅仅是通过互斥的方式来实现,不过未来如果有了争用问题,也许可以扩展为专门的调度器),同时在各类型之间保持良好的包装。如果某个系统要读取组件A和B的所有组件数据,那么它只需要检查分配的类型(由一个位掩码决定)。然后系统就会立即知道其中(数量可能成千上万的)实体是否与它感兴趣的类型匹配。 

这种模式匹配方法使数据转换逻辑与数据的构成完全解耦,让系统能够在不了解非相关部分的情况下并行操作许多不同的组件数据组合,同时仍然保证内容上规模以后的性能。如果要对以上示例进行扩展,插入一个能够相对于变换原点生成所有变换轨道的系统,那只是小菜一碟,并不需要对任何核心变换系统进行侵入式更改。
Figure 8
图8--从4.26版起使用变换原点的变换轨迹求值

更新阶段

Sequencer系统能够在任何数量的不同阶段中运行,这些阶段依据情境运行,每一个都有自己的名称和限制。这些边界有助于强制执行Sequencer中一些较为严格的排序要求,同时仍然使异步求值逻辑能够用于大部分每帧运行的系统。这些系统大致可以分为两种:仅在越过边界时或绑定失效时运行的系统(生成阶段和实例化阶段),和每帧运行的系统(求值和终结)。 
  • 生成:专门容纳可生成逻辑和必须在解析绑定前或解析绑定后发生的事件。无法调度异步任务。
  • 实例化:包含任何希望创建/销毁实体的系统,或添加/删除Linker->EntityManager中组件和标记的系统。此阶段仅在实体管理器结构已经改变或者对象绑定已经失效时运行。无法调度异步任务。
  • 求值:需要每帧运行以产生已求值状态的系统。无法改变实体管理器的结构(即添加或删除实体或组件)。大部分系统将在这里进行。
  • 终结:在帧结束时运行。

这些阶段的区别使系统可以仅在绝对必要时才运行成本较高的设置/拆解逻辑,而使90%的通用代码路径尽可能保持精益。

生成阶段和实例化阶段仅在实体管理器中已发生结构更改时运行,例如对新的分段求值时,不再对分段求值时,或者绑定失效时。这些阶段通常执行成本很高的任务,例如解析属性,改变实体结构,或缓存预动画数值。在这些阶段中,实体管理器可以发生更改和变异,而在求值阶段和终结阶段,实体管理器是被锁定的,无法进行结构上的更改(尽管组件数据仍然可以被读写)。

这是运行Sequencer求值的典型序列流。你可以看到,在任何序列中,如果不会越过边界,就只会执行求值和终结阶段。 Figure 9
图9--Sequencer求值系统阶段更新

线程

因为我们现在能够按照逻辑所读写的组件类型来定义逻辑,所以也可以定义组件数据的上游和下游依赖性,这样一来就可在有必要的情况下安全而自动地将这些数据变换异步分派到任务图表。在对大型数据集(例如有许多复杂关键帧曲线的内容)求值时,这些曲线的求值现在能够与其他计算一起异步运行,在平台允许的情况下实现并发性。这只有在情境切换或线程抢占的潜在成本合理的情况下才有益,但Sequencer的线程安全设计现在允许在内部或由用户自主做出这一决定。

我们将在适当的时候为使用Sequencer的程序员更全面地详细阐述该架构。
 

从4.26版起相对于旧运行时系统的行为变化

一般而言,你应该不会体验到任何行为变化;可生成项、附件、变换和其他属性应该都会继续像以前一样运作。但是,也存在一些细微的行为变化,概括如下:
  • 同一类型的活动序列现在都会同时在一起求值。在4.26版之前,如果有两个独立的活动序列对同一对象上的同一属性生成动画,由于它们是分别求值的,就会争夺该属性的控制权。在4.26版中,这些序列将会一起求值,因而可以自然地混合在一起。将来我们还会扩展这一设计,允许用户控制序列混合或相互覆盖的方式。
    • 所有活动窗口控件动画现在都会一起求值。
    • 所有活动关卡序列现在都会一起求值。
  • 如果手动播放、停止或设置序列或窗口控件动画的位置,现在可能无法在该函数调用中对序列同步再求值。播放或移动序列播放头的请求可能被延迟到下一帧。
    • 可以通过在序列上禁用“异步求值”选项来为特定序列禁用这一功能,但需要付出性能成本。这样做会强制进行同步再求值,并且禁用(1)中描述的混合行为。
Figure 10
图10--异步求值序列检查 
  • 带有缓和曲线的绝对混合轨道现在会从对象的初始值混合。在4.26版之前,系统一定会将混合权重规格化,即使只有一个参与者也不例外。这意味着相对到绝对的混合需要两个分段。
    在4.26版中,相对到绝对的混合可以简单地表达为一个绝对分段,其中含有一个缓入/是的缓出。在下面的图11中,你可以看到带有缓入/缓出的绝对混合的示例。 

 
图11--绝对混合的示例
 
  • 导致相对于序列求值的重入的事件轨道(例如播放其他序列、更改播放状态或移动播放头的事件)现在仅限于求值后位置(即不允许出现在“生成前”或“生成后”)。这是现在新事件轨道的默认设置。在4.26版之前,事件轨道默认在“生成后”。虽然现有轨迹将会保留“生成后”设置,但新的轨道将默认设置为“求值后”。 
Figure 12图12--将事件轨道位置改为“求值结束时”

重大API更改

UE::MovieScene命名空间
新的Sequencer基本代码完全包含在UE::MovieScene命名空间中,这是为了减少对于过多类名前缀的需求。作为此工作的一部分,包含在现有MovieScene命名空间(对它的使用并不多)中的所有代码都已移动到UE::MovieScene,以确保一致并遵守当前编码标准。

虽然可能性不大,但第三方代码有时会引用旧的MovieScene命名空间,此时需要将类型或函数更改到UE::MovieScene

UMovieSceneTrack
如果用户有自定义的轨道实现,与轨道模板的编译相关的API已经迁移到一个独立的接口(IMovieSceneTrackTemplateProducer),这是为了更好地分隔用于求值的不同方法。如果要定义CreateTemplateForSectionCustomCompilePostCompile等函数,应该将此接口添加到你的UMovieSceneTrack类型,因为它们已经不存在于基类上。

轨道/片段混合器
GetRowSegmentBlenderGetTrackSegmentBlender现在已被淘汰,不再调用;轨道求值字段现在会在修改时重新生成并保存到资源中,因此不需要在编译时重新计算。

UMovieSceneTrack::PopulateEvaluationTree是用于定义自定义重叠行为的新方法,使用在FEvaluationTreePopulationRules中可用的内置算法。

UMovieSceneSection
为了支持通过IMovieSceneTrackTemplateProducer::CreateTemplateForSection定义模板,现在已移除UMovieSceneSection::GenerateTemplate

ChannelProxy
在第三方分段类型具有动态信道布局的罕见情况下(即在构造函数之外重新创建ChannelProxy),应该在新函数virtual EMovieSceneChannelProxyType CacheChannelProxy()中定义其构造。

总结

虽然现在只有可生成项、附件、事件、2D/3D变换和浮点属性移植到了新的运行时系统,但我们已经看到大量使用这些功能的内容获得了显著加速。我们对Sequencer的特定问题域应用面向数据的设计原则,不仅实现了全面的优化改进,也更好地在运行时中分离了问题,以此为基础,我们将能够在保持高性能的前提下构建新的功能,例如关键帧/轨道/子序列参数化、全局混合和更好的调试工具。
以下示例显示了500个自带手动关键帧动画的独立跑车Actor在4.25版和4.26版中运行的对比。经过测量,手动调用SetRelativeTransform的开销大约是2.5毫秒,这表明在Sequencer中,开销优化到了原有水平的7.5倍左右。
 
图13--SetRelative Transform成本的性能差异演示
 
我们希望你觉得这些信息既有趣又有用。要了解关于Sequencer的更多信息,请查看该工具的文档页面