前言

笔者最近在分析UE5管线,发现cpp端常量缓冲区的绑定、渲染状态的绑定看起来很折磨,不知道为什么UE5会这样做,为什么与DX12完全不同,以及这样做有什么好处,这篇文章将一一解答

重要组件

cpp常量缓冲区的绑定、渲染状态的绑定都需要Level的数据,这些数据怎么传递到管线的呢?

UE5通过在mesh上挂载component,通过Componet将需要的数据传递给引擎,从而进行绑定

这里提及几个必要的Component:

Class 作用
UWorld 相当于一个地图,所有的 Actor 和组件都在这里生成和渲染
ULevel 分为PersistentLevel、Streaming Level
Persistent Level: UWorld 的“主心骨”。双击打开一个地图资产时,这个资产就成了当前的 Persistent Level,在这个 UWorld 的整个生命周期内永远存在,绝对不会被卸载
Streaming Level:是动态的。可以随时把它们加载到 UWorld中,或从 UWorld中卸载掉
USceneComponent 引入了 Transform 和父子层级
UPrimitiveComponent 赋予Actor可渲染、进行物理交互的功能,由Game Thread驱动,包含Actor所有的数据
FScene 容纳所有由Component发送的Actor 数据,Actor的数据仅被添加到FScene(注册组件时调用)后才会存在于Render
FPrimitiveSceneProxy 由UPrimitiveComponent创建,打包所有GPU需要的数据,由Render Thread驱动,保证线程安全
FPrimitiveSceneInfo 一个渲染所用数据,由FScene根据FPrimitiveSceneProxy,计算打包的渲染数据
FSceneView 存在于Engine,存储与当前camera相关的数据
FViewInfo 存在于Render,存储当前camera下的渲染数据
FSceneViewState 存放上一帧的渲染数据——是这一帧需要的数据
FSceneRenderer 渲染器,渲染管线的各种效果都是在这实现

可以把它们分为Engine部分与Renderer部分:

Engine Renderer
UWorld FScene
UPrimitiveComponent FPrimitiveSceneInfo
FPrimitiveSceneProxy FViewInfo
FSceneView FSceneViewState

DrawList

在UE4时,UE大部分时期使用的DrawList而不是FMeshDrawCommand,到后面UE才对管线进行了重构,从DrawList转到了FMeshDrawCommand,在正式了解Mesh Drawing Pipeline之前,依然需要知晓为什么UE会弃用DrawList,它有什么缺点

早期的渲染流程

在UE中Game Thread与Render Thread是分开的,一般Render Thread会比Game Thread晚一帧

  • 为了保证线程安全,这里会在渲染线程中为游戏线程中的所有物件创建一份渲染数据,即FPrimitiveSceneProxy

  • 但在渲染时并不会直接使用FPrimitiveSceneProxy,而是FMeshBatch,为了将渲染数据转换成渲染时使用的数据,对于动态/静态物件,可以用GetDynamicMeshElements()/DrawStaticElements()来得到FMeshBatch

    • 什么是FMeshBatch

    一种将FPrimitiveSceneProxy的实现与Pass解耦的结构,这个结构包含每个Pass渲染所需要的所有数据(Vertex Buffer,Shaders,Parameters等)

  • 拿到FMeshBatch后,就需要考虑如何渲染。UE使用到了DrawList,这个结构包含所有FMeshBatch、FPrimitiveSceneProxy数据;此外还用到了DrawingPolicy,这个结构涵盖如何渲染某个Pass(如绑定Shader)。DrawingPolicy遍历DrawList的FMeshBatch,输出RHI Command List

    • DrawingPolicy遍历

    DrawingPolicy会先绑定渲染状态,然后遍历Material Chain,找到material instance的数据

  • 最终调用Draw接口

DrawList的缺点

  • 缺点

    • 使用bit array(二进制)表示物件可见性,bitarray涵盖场景中所有物体,导致在部分渲染逻辑中无法快速找到视线最前面的可见物体(因为这里仅仅根据二进制表示场景中所有物体的可见性,并不涵盖空间信息,不知道谁在前谁在后)

    • 无法实现静态物件跟动态物件的Draw Sorting,导致性能提升不能更进一步

    因为早期使用TStaticMeshDrawList存储静态物件,这是一个永久缓存的数据结构;而动态物件是每帧临时生成

    这就导致在Draw时,GPU不得不先画静态物体,再画动态物件。如果这些物体就一种材质其实还好,但一般都存在很多不同的材质,导致GPU需要先画完一种材质切到下一种材质,在这种情况下切换开销几乎是2N

    另外一点,就是Overdraw,因为不知道静态物体和动态物体先前谁后

    • DrawList以Shader Permutation(变体)为模板参数创建,导致存在大量的不同种类的DrawList,使得代码很丑很难维护

    • 只有单个硬编码的Frequency数据可以用来完成State Sharing,这也就意味着,我们只能通过硬编码来实现instancing

    • Frequency数据:早期constant buffer都是按使用用途分类(Per-View、 Per-Pass、Per-Material、Per-Object),且上传频率各不相同

    • State Sharing

      要实现Instance,早期需要专门为某种物体定制shader,将Per-Object里塞入instance需要的数据。如果只制定一种物体还好,但若种类非常多,需要不断定制,很繁琐

简单来说,就两点,1是性能问题,2是代码又臭又长

Mesh Drawing Pipeline

为了避免DrawList带来的问题,UE对渲染流程做了修改,主要移除了DrawingPolicy、DrawList

被删除的部分使用FMeshDrawCommand代替,并新增FMeshPassProcessor、SubmitMeshDrawCommands与FMeshBatch与RHICommandList连接

什么是FMeshDrawCommand

下图是FMeshDrawCommand的定义,有三个特点:

  • stanalone:与FMeshMeshBatch相比,FMeshDrawCommand更加干净,并没有指针对象,是stanalone

    不需要通过指针对象获取数据,直接存储在FMeshDrawCommand

  • 不依赖Context:在旧的流程中,需要根据Pass执行分支判断,设定光栅化设置、深度测试等,比较耗时;但在新的流程中,在启动前将这些提前打包编译为一个不可变的对象——PSO

  • Data-Oriented:在旧的流程中,FMeshBatch知道自己归属哪个对象。但在新的流程中,FMeshDrawCommand并不自己自己的归属,取而代之的是一串只包含底层渲染参数的纯数据块

    这样就可以对所有物体排序

Mesh Drawing Pipeline的优点

  • Cached Mesh Draw Commands

    FMeshDrawCommand与Pass牢牢绑定,且FMeshDrawCommand存储的数据都是不用每帧更新,在最开始就可以确定的。因此物体在被导入Level时(),只需要计算一次FMeshDrawCommand,后续无需再计算,并永久缓存

  • 更稳定的Draw Call Merging

    在早期管线中,MaterialInstance能否合并,取决于两个物体的Material Instance是否是同一对象(地址),即使材质信息完全相同;而在新的管线,能否合并仅仅取决于FMeshDrawCommand的资源绑定是否相同

MeshPassProcessor

从上图看出,UE使用FMeshPassProcessor来生成FMeshDrawCommand,这里具体讲一下是如何生成的

  • FMeshPassProcessor决定Pass使用的shader,以及shader bindings——pass、vertex factory、material

  • AddMeshBatch()
    可以看到函数实现不再是基于模板

    Pass Filter判断当前物体是否需要当前Pass渲染
    Select Shader选择需要用到的Shader

    Process():完成Shader Bindings,并构建DrawCommand

    BuildMeshDrawCommands():无需订制,多Pass共用

    Shader Binding:不再直接绑定到RHICommandList,而是绑定在MeshDrawSingleShaderBindings结构体

DrawCommands排序

由于Static/Dynamic都使用统一的DrawCommands,因此可以将两者一起排序

在新的管线中,我们将拿到一个对应于可见物件的物件数组,而非老管线中的整个场景中所有物件

这里可能不好理解,其实在老管线下,数据是被打散的——静态物体在一个全局的 DrawList,动态物体存在每一帧临时生成的几个小数组,半透明物体可能又在另一个列表,而且还包括了物理上不可见的物体;但在新管线,会先在InitView()做剔除,引擎会提取可见物体的FMeshDrawCommand,将他们塞入数组,对它们一视同仁

Submit

排序完成后,就可以Submit了

由于新管线FMeshDrawCommand是已经确定的且数据独立,且是数组结构可以知晓整体数量,因此新管线下SubmitMeshDrawCommands()是可以并行的

FMeshDrawCommand的缓存

为什么需要缓存FMeshDrawCommand

  • 由于World的绝大部分物体都是Static的,因此一次DrawCommand生成,多次使用是没有问题的

  • 由于重新绑定开销很高,DrawCommand必须满足变化频率很低这一特性,为了弥补这一特性,可以采用Uniform Buffer存储数据,而不存储在DrawCommand

如何缓存

  • 没必要每帧都创建Uniform Buffer,只有当Uniform Buffer内部数据有变化,才创建

  • 当AddMeshBatch()的引用对象与Shader Bindings有所变化,需要标记FMeshDrawCommand为Invalidated,让CPU重新build一个FMeshDrawCommand

  • 如何检测是否遗漏Invalidated标记呢?可以用到引擎自带的宏VALIDATE_UNIFORM_BUFFER_LIFETIME
    VALIDATE_UNIFORM_BUFFER_LIFETIME默认为0,要启用检测为1

  • 不同类型的物体有不同的更新方式

    • 动态物体:MeshBatch -> DrawCommand -> CommandList都是每帧更新

    • 需要SceneView的静态物体:可以缓存MeshBatch,但需每帧更新 DrawCommand -> CommandList

    因为shader bindings有变化

    • 不需要SceneView的静态物体:可以缓存MeshBatch、DrawCommand ,需每帧更新CommandList

  • 来看一个例子

    • 加载时(AddToScene),计算 MeshBatch 并存入 FPrimitiveSceneInfo。在渲染时,交由 MeshPassProcessor 根据当前的 SceneView,生成这一帧的 DrawCommand
    • 当天光重设时,需要将已缓存的DrawCommands的有效状态重置为无效
    • 执行InitViews()(每帧)时,需要遍历每个Primitive,如果是静态的,计算LOD,并将此LOD对应的DrawCommand添加到visible set;如果是动态的,就需要完成MeshBatch的重建
    • RenderDepth调用visible set中的每个Mesh DrawCommand完成绘制

如何合并

DrawCommands很容易就能实现Dynamic Instancing,因为DrawCommands不包含上层代码,十分自立,所以只需简单地比对就能判断DrawCommands是否能够合并

更重要的是,这种合并逻辑不需要美术做额外的设置,完全自动

但是,也不能每帧都合并,因此UE基于不同的Render States生成多个bucket,把缓存的DrawCommand的渲染状态塞入对应bucket,在使用时可以快速查找,将状态比较降维打击为ID比较
后续还会对屏幕上可见的DrawCommand 收集在一个数组,并根据DrawCommand的StateBucketId排序。顺着数组扫,只要相邻的 ID 一样,计数器就 +1;一旦 ID 变了,直接打断,发起DrawIndexedInstanced

如何快速分析Pass

分析Pass大致分为四个步骤

看Pass位置

  • 目的:需要先确定这个Pass大致在渲染管线哪个位置,比如PrePass是在Base Pass之前还是之后

  • 找什么:GraphBuilder.AddPass

看Constant Buffer

  • 目的:查找当前Pass 从c++端传给GPU端的变量、贴图、Sampler
  • 找什么:在cpp端查找BEGIN_SHADER_PARAMETER_STRUCT

找渲染状态绑定

  • 目的:查找cpp如何设置PSO、Blend、Depth、Stencil、Shader变体

  • 找什么:找当前pass的FMeshPassProcessor,重点看其中的Process()、AddMeshBatch()

    但在UE5.7,笔者测试的UE大幅利用到了并行特性,在InitViews()阶段就计算好FMeshDrawCommand,而不是串行一个一个pass AddMeshBatch(),直接装进ParallelMeshDrawCommandPasses,因此每个Pass不会再AddMeshBatch(),我们只需要看Process()

找Shader绑定

  • 目的:查找这个Pass具体是怎么计算的,用的什么算法

  • 找什么:cpp端查找IMPLEMENT_MATERIAL_SHADER_TYPE

  • IMPLEMENT_MATERIAL_SHADER_TYPE

    • 定义
    #define IMPLEMENT_MATERIAL_SHADER_TYPE(TemplatePrefix,ShaderClass,SourceFilename,FunctionName,Frequency) \
        IMPLEMENT_SHADER_TYPE( \
            TemplatePrefix, \
            ShaderClass, \
            SourceFilename, \
            FunctionName, \
            Frequency \
            );
    
    • TemplatePrefix:如果Shader class是template class,则填写模板前缀。普通class则不写,或者写template<>
    • ShaderClass:继承自 FMaterSialShader 的类名——即Pass Class
    • SourceFilename:.usf shader 文件路径
    • FunctionName:入口函数
    • Frequency:shader类型——VS、PS、CS

Reference

虚幻引擎的Mesh Drawing Pipeline
Refactoring the Mesh Drawing Pipeline for Unreal Engine 4.22


他们曾如此骄傲的活过,贯彻始终