线程渲染

渲染线程

在虚幻引擎 4(UE4)中,整个渲染器在其自身的线程中执行操作,该线程位于游戏线程的一两帧后。

执行渲染操作时,必须仔细地考虑内存读写,确保线程安全,以及行为的确定性。功能行为取决于两个线程之间的执行速度差,这种情况被称作竞争条件。需要尽量避免竞争条件的出现,因为它们难以重现;且因为速度差的缘故,它们可能依赖于机器、平台、调试器或配置。这类 bug 很难进行调试,所花费的修复时间约为可重现的普通 bug 的 10 倍。

这是一个竞争条件/线程 bug 的简单例子:

/** 组件注册到场景时,游戏线程上将调用 FStaticMeshSceneProxy Actor。*/
FStaticMeshSceneProxy::FStaticMeshSceneProxy(UStaticMeshComponent* InComponent):
    FPrimitiveSceneProxy(...),
    Owner(InComponent->GetOwner()) <======== 注:AActor 指示器已缓存 
    ...

    /** 渲染器在场景上执行一次通路时,渲染线程上将调用 DrawDynamicElements。*/
    void FStaticMeshSceneProxy::DrawDynamicElements(...)
    {
        if (Owner->AnyProperty) <========== Race condition! 游戏线程拥有所有 AActor / UObject 状态,
            // 并随时可能对其进行写入。UObject 可能已经执行过垃圾回收,导致程序崩溃。
            // 在此代理中镜像 AnyProperty 的数值即可安全执行操作。
    }

开发方法

不存在通过彻底测试找到竞争条件的方法。理解这点十分重要:猜测检验或消极的 bug 修复无法创建可靠的线程代码。最好的方法是完全理解游戏线程和渲染线程的互动,并使用机制保证确定性。应具备能力解释使每个互动具有决定性的事件顺序,否则定会出现竞争条件。

线程特定数据结构

因此可取的方法是 - 将数据保存在不同线程“拥有”的单独结构中,明确修改者和修改对象。此法同样适用于函数。最佳方法是 - 固定从相同线程或极为复杂的情况中调用每个函数。UE4 的大部分结构皆为如此。例如,UPrimitiveComponent 是属于可被渲染、可投射阴影资源的基础游戏线程类,拥有其自身的可视状态等属性。渲染线程无法直接触及 UPrimitiveComponent 的内存,因为游戏线程可能随时写入其构件中。渲染线程自身拥有代表此功能的类 - FPrimitiveSceneProxy。游戏线程被创建和注册后,无法触及 FPrimitiveSceneProxy 的内存构件。UActorComponent::RegisterComponent 将一个组件添加到场景,并创建一个 FPrimitiveSceneProxy 使其对渲染器可见。组件注册后,如其为可见,将为所需的每次通路调用 FPrimitiveSceneProxy::DrawDynamicElements

性能注意事项

游戏线程将在每个 Tick() 事件的末尾阻塞,直到渲染线程赶上一到两帧的差距。因渲染线程十分滞后,在游戏进程中阻塞游戏线程,等待渲染线程完全赶上的方式完全不可取。在读取或单个物体垃圾回收时进行阻塞也不可取,因为 UE4 支持异步流关卡。诸多操作均有异步机制,防止阻塞。

线程间通讯

异步

两个线程间通讯的主要方法是通过 ENQUEUE_UNIQUE_RENDER_COMMAND_XXXPARAMETER 宏进行。此宏使用虚拟的 执行 函数(包含输入宏的代码)创建本地类。游戏线程将命令插入渲染命令队列,渲染线程在开始时调用执行函数。

利用 FRenderCommandFence 可在游戏线程上方便地追踪渲染线程的进度。游戏线程调用 FRenderCommandFence::BeginFence 开始栅栏。然后游戏线程将调用 FRenderCommandFence::Wait 进行阻塞,直到渲染线程处理栅栏;或者检查 GetNumPendingFences,轮询渲染线程的进程。当 GetNumPendingFences 返回为零时,渲染线程已经处理栅栏。

阻塞

FlushRenderingCommands 是阻塞游戏线程直到渲染线程赶上的标准方法。这在离线(编辑器)操作中十分有用,通过渲染线程修改使用的内存。

渲染资源

FRenderResource 提供基础渲染资源接口、以及初始化和释放的挂钩。从 FRenderResource(FVertexBufferFIndexBuffer 等)派生出的资源在用于渲染前需要被初始化、在被删除前需要被释放。FRenderResource::InitResource 只能从渲染线程调用,因此游戏线程上可调用一个辅助函数(BeginInitResource),使渲染命令入列,以便调用 FRenderResource::InitResource。RHI 函数只能从渲染线程调用(创建设备、视口等除外)。

UObjects 与垃圾回收

Garbage Collection(GC)在游戏线程上发生,并运算 UObjects。渲染线程正在处理引用 UObject 的命令时,游戏线程可能将 UObject 删除。因此渲染线程不应该解除 UObject 指示器的引用,除非有机制能确保 UObject 被渲染线程引用时不会被删除。以 UPrimitiveComponent 为例,它使用一个称为 DetachFence 的 FRenderCommandFence 防止 GC 在渲染线程处理分离命令前将 UObject 删除。

游戏线程 FRenderResource 处理

需要考虑的游戏线程渲染线程交互常见情况有两种:静态资源(只能在加载后或编辑器中进行编辑,与索引缓冲相似)和动态资源(需要将游戏线程模拟的最新结果更新到每帧)。

静态资源

本节讲述 UE4 中如何处理静态资源交互,以 USkeletalMesh 为例。

该机制高效(不阻塞线程、在中心位置进行初始化,而不在每帧检查是否需要初始化)而具有决定性,十分实用。

动态资源

动态资源更新的一个最佳范例是游戏线程动画每帧生成的骨骼网格体骨骼变形。目的是:在每个动画更新进渲染线程上(在此可将变形设为着色器常数)的一个阵列后,从游戏线程中获取变形。如在每帧更新索引或顶点缓冲,结果相同。以下是操作顺序:

更新状态 VS 遍历渲染场景

在开发一个拥有独特更新和渲染操作的系统时,将两者合并进 DrawDynamicElements 看上去很美,而实际上却是不是个好点子。更好的方法是将更新从渲染遍历中独立出来,例如使来自游戏线程 Tick 事件的更新入列。

通过高阶渲染代码调用 DrawDynamicElements,绘制原始组件的元素。高阶代码假定不对 RHI 进行改变,在每帧中可将 DrawDynamicElements 调用任意次(取决于着色通路、画面数量、以及场景中的场景捕捉)。甚至可能调用 DrawDynamicElements,但底层绘制规则会因为多种原因放弃结果(例如:深度通路中提交的半透明 FMeshElement 将被放弃)。如原始组件实际为不可见,遮挡系统可能会/不会实际调用 DrawDynamicElements(取决于其使用的启发法)。这些所有因素均可能和每帧发生一次的状态更新产生冲突。

更好的解决方法是将更新和渲染遍历独立开来。游戏线程 Tick 事件可使渲染命令入列,执行更新操作。渲染命令可基于可见性略过更新。如使用情况允许,可使用原始场景信息的 LastRenderTime 执行操作。如更新操作以这样的方式单独入列,任意 RHI 函数皆可使用(包括设置不同的渲染目标)。

状态缓存(与更新相反)是此规则的例外。状态缓存将渲染遍历的中间结果作为优化保存。它与遍历密切相关,且不改变 RHI 状态,因此它不受上文提到的负面影响(设置正确时机进行缓存即可)。