基于 Unity SRP 的 Render Graph 来满足多个在研Unity项目多平台、高质量、多风格的渲染要求。
目录
Transient resource system: The back-bone of FrameGraph
简介&历史
2007时是DICE的引擎,用于未来的battleField的产品,多平台DX9&10。10年后,2017年Frostbite成为EA的标准引擎,多平台DX12,在15款游戏以及未来的EA游戏中使用。
07年的rendering system
10年后,加了很多新特性,新平台,解决了很多新问题。
简化的rendering system。最底下是Platform Graphics API,上面是Render Contex作为API的抽象,在上面Shading system 07年介绍过,用于渲染整个世界,而且是根据美术在ShaderGraphs中的连线构造的Data-Driven Architecture。ShaderGraph定义了surface properties,对光照进行了解耦,来帮助我们创建一批shader可以用于deferred/forward;
shading system被渲染features驱动,比如地表渲染,mesh scattering,rigid geometry,一些渲染features会直接使用Render Contex,大部分是一些全屏处理,比如lightng,pp等,RC是code-driven的架构;最上层有一个庞然大物world render,来管理整个世界。
这是一个code-driven的架构,world render知道所有的渲染特性,views & passes,它分配出资源(RT,buffer),在不同的system中设置并调度它们。
BF4中使用的rendering pass(直接作用于Render Contex的features),很多产品项目组的程序美术会添加各种各样的new feature进来。
WorldRender
World Render的挑战:
- 显式的立即的模式进行渲染;
- 显式的资源管理,各个产品项目组都会定制、手工的管理ESRAM,对资源的统一性和merge造成了影响;
- 和渲染框架耦合严重,因为显式的定制渲染和资源,所以很多模块都受到对方的影响;
- 局限的扩展新;产品组必须fork来定制;10年间,从4k行代码涨到15k行,非常困难去维护、扩充、整合。
WorldRender的目标:
- 想知道高层整帧的概况;
- 更好的扩展性,解耦并组合渲染模块,自动化的资源管理,更好的可视化和分析工具。
为了达到目标,引入了两个新的架构组件:Frame Graph以及Transient Resource System。
Frame Graph
Frame Graph用来高度抽象表示render pass和资源,它了解一帧之内所有的信息;Transient Resources来帮助FG分配资源,管理memory aliasing。
FG的目标:
- 构建高层对整帧的了解,简化资源管理、简化渲染管线配置、简化async compute以及resource barriers;
- 允许我们写独立的高性能渲染模块;
- 可以可视化以及debug复杂的渲染管线。
这是一个Deferred Shading pipeline,橙色代表render passes,蓝色代表资源,箭头代表render pass之间的依赖关系,红色是write操作,绿色是read操作。
BF4的一帧,大概有几百个render passes和resources,一个可以线性分析的可视化工具,可以顺序的显示render passes和resources。
最上面是render passes,包括graphic和async compute;当鼠标移到某个render pass上方时,可以看到那些resources是需要被当前rp读写的(绿色/红色);
当移动到resource上时,可以看到对它进行读写的render passes
最下方可以看到这些数据的物理内存分布,由transient resource system计算。
移除了立即模式的渲染,新的rendering代码可以分割为passes,rendering分为了3个阶段:
- setup设置使用哪些render passes,哪些resources(定义每个passes的inputs和outputs资源);
- compile负责resources的lifetime,并且给他们分配空间;
- execute就是执行每个render pass。
Render passes通过resources来建立互相的关系,这儿resources是handles并没有内存分配,rp必须定义所有用到的资源,包括读写和创建;有些永久资源直接通过API创建,它们会被导入到rp中一起进行管理,比如TAA的history buffer/backbuffer等。
在setup阶段,Flow类似于IM渲染,但在此阶段我们不会生成任何GPU命令。只是建立有关帧渲染操作的信息。在图形构建过程中,所有资源都是虚拟的。渲染过程的输入和输出是使用虚拟资源句柄声明的。
我们也可以读或者写之前创建的资源,这儿rp读一些资源并且写入一些资源;我们写入的资源之前老的handle会被invalidated,在写操作后我们会重命名这个资源,写入invalid handle的资源会造成runtime error,这会帮助我们找到一些之前老代码就存在的数据竞争与依赖。
高级FrameGraph
- 延迟创建资源,很早的声明资源,直到使用时才创建,根据使用自动设置创建的desc;
- 推导资源参数,可以根据input的格式/size来推导参数,比如在resolve pass需要一个downsample chain,简单的可以自动直接推导出创建资源。
- 移动子资源,可以帮助我们直接在多个pass复用资源,自动的创建子资源或者资源别名。
有Deferred Shading,最终会输出2D RT;另外我们还有一个Reflection模块用来做一些convolution,他需要一些cubemap作为input,我们可以使用move操作把lighting buffer输出给cubemap的faces,所以lightbuffer会使用cubemap的subresource view来代替整个2D rtview,复用了lighting buffer的资源。这帮助我们解耦了各个模块,ds和refl模块之间不需要互相了解任何信息。
Compile阶段
这个阶段不会给program user暴露,自动运行。把没有引用的res和passes cull掉,这样在setup阶段可以草率一些,更容易解耦,简化了可选的passes以及debug rendering等;计算res的生命周期;根据生命周期和bind flag分配固定的GPU资源,对于async compute会延长其生命周期。
graph culling的重要性:DS会有一个debug模块,我们就一直加上他,不需要知道它是否开关,正常渲染时会被cull掉。
Culling algorithm
Simple graph flood-fill from unreferenced resources.
Compute initial resource and pass reference counts
renderPass.refCount++ for every resource write
resource.refCount++ for every resource read
Identify resources with refCount == 0 and push them on a stack
While stack is non-empty
Pop a resource and decrement ref count of its producer
If producer .refCount == 0, decrement ref counts of resources that it
reads
Add them to the stack when their refCount == 0
执行阶段
执行每个rp的callback函数,里面的代码和没有FG之前的一样:包括使用RC、设置state等,dispatch和draw;唯一不同的是这儿需要根据handle去devirtualize真正的GPU资源。
异步计算
SSAO读取depth输出到raw AO上,filter把raw AO处理为AO,之后作为输入给Lighting pass,AO变为async compute,这样rawAO的声明周期就延长至lighting pass(main queue中第一次使用其output依赖的地方)
lambdas
重要的是在C++中怎么声明RP:可以对每个RP声明一个class,但是这样就会破坏code
flow(每个class分割了procedure的代码),需要一堆样板参数,对于现有代码难移植;使用了lambdas来解决,可以保证线性的代码流,最小化改变已有代码(把原有代码包进lambda中,加入资源的使用声明)
第一段包含rp使用的resources声明;接下来是setup phase的lambda,声明了resources如何使用;最后是execution phase的lambda,会在之后才执行,也许会被cull掉不执行;lambda能帮助我们自动捕获需要的变量,对于setup phase逻辑上应该可进行读写设置,捕捉引用&,对于execute phase逻辑上应该是延迟执行,捕捉使用值传递=。
addCallbackPass()是一个模板函数,它在后台创建一个由PassData和executelambda参数化的渲染过程类。Setuplambda是在addMyPass()中内联的,但executelambda是deferred的。Setuplambda可以通过引用捕获所有内容,但executelambda必须通过值捕获。
按值捕获数据有点危险,因为可能会意外捕获在执行阶段之前释放的指针。也有可能意外地capture huge structures by value。幸运的是,我们可以强制executelambda的大小在编译时低于一定的大小(我们确定了1KB的限制)。
RenderMode
Render modules:2种渲染模块:1)Free-standing stateless function
第二种是类似TAA的history buffer这种,生命周期大于一帧。WorldRenderer依然在高层协调整个渲染,但是它不会分配任何GPU资源,仅仅kick渲染模块,更加简单去扩展,代码量从15k行变为5k行。
之前渲染模块之间显式的通过参数和返回值传递数据,这种机制非常难以扩展因为需要改变函数声明或者结构定义。新的模块通过一种storage(hash table) component传递数据,key是typeid,这样的耦合是可控的。我们想让模块之间可以传递数据,但是又不行暴露给外界;举个例子:tonemap模块需要blur模块的数据,blur模块通过blackboard.add把相应数据加入hashtable,tonemap模块从blackboard中get出blur模块里的数据,这样blur模块不需要知道是否有tonemap的存在。
Transient resource system: The back-bone of FrameGraph
对于一帧之内临时的资源,我们希望最小化它们的生命周期。在真正使用时才分配资源,可以在渲染系统的叶子结点模块分配资源,尽快的回收,更容易的写独立包含的代码,不需要考虑其他模块/全局的资源传递创建等。它对于FG是非常重要的。
这是实际ps4中的memory map,x轴是时间,y轴是virtual address。Ps4中的trs首先会申请一大段virtual memory(例如几个G)但是没有物理内存的分配,在每帧的执行期间当我们需要一个新的rt时,用户申请数段chunks of memory并且真正分配物理空间,资源的handle会指向申请的内存,等待使用完毕我们可以复用它的virtual memory,可以在一帧开始计算需要多少GPU内存,事先分配好,在execute的时候使用。坏处是可能会造成内存碎片的浪费,因为使用greedy算法来分配回收内存,例如图示中的AO buffer回收后会造成不连续的碎片,但实际中不是太大的问题。
在DX12中有些不一样,GPU的分配会抽象为resource heap,分配之前需要找到满足条件的resource pool,使用placedResource(DX12 API)来创建heap中的资源,track heap中的资源来复用。这不仅会有ps4碎片的缺点,而且会带来更多per heap的细小内存碎片,但实际上使用了aliasing的管理还是比不使用任何内存管理效果好得多。
需要仔细考虑:必须小心,首先需要确定资源的metadata state(fastclear/discard/diable),其次要确定资源的生命周期,比想象中的要难很多,要考虑render和compute,保证在使用前已经分配了真正的物理内存页。
SUMMARY
知道每帧全局的信息有很多好处(复用内存,半自动化的async compute,简单的管线配置,可视化和debug工具);图表pipeline的表示非常吊,有非常直观的感受,类似cpu job graphs或者shader graphs,C++的新特性减少了重构的麻烦。