Halcyon框架介绍

2020-04-07 10:00:00

背景

Halcyon是EA SEED(Search for Extraordinary Experience Division)工作室开发的一个R&D框架。不同于EA DICE开发的Frostbite引擎,Halcyon只是一个框架,称不上是一个完整的游戏引擎(参考叶大的《游戏引擎架构》)。它的主要目的是方便美术和gameplay程序员快速的做出一些游戏原型,而不是用于做AAA游戏。但Halcyon也并不是一个简单的只用于尝试某些图形效果的渲染器,它是一个对过去引擎技术的思考和沉淀,以及对新技术的尝试。Halcyon针对一些前沿技术进行了抽象和封装,并且实现了Windows、Linux、macOS的跨平台(没有mobile和console)。

为了美术可以快速的迭代资源,在Halcyon里,所有的资源都是可以热更新的,不用频繁的导入资源和重启编辑器,也不用为了适应传统引擎的occlusion、GI、collision、LOD规则去对资源做特殊处理。美术可以专注在设计和创造,而不是把时间花在繁琐的资源处理流程上。

图形方面,因为Halcyon不用考虑兼容旧平台,是专为现代图形API设计的,支持DX12、Vulkan1.1、Metal2。同时支持同时使用多个GPU,而且并不是基于CrossFire或者SLI的方式,意味着兼容性会更好,甚至可以混用显卡(N卡+A卡)。另一方面,Halcyon实现了基于GPU的渲染管线,而且用到了光线追踪技术,甚至可以在不同的渲染阶段对光线追踪进行开关,混合使用光栅化和光线追踪。Halcyon不仅实现了本地渲染,也实现了串流渲染,为将来的云平台,云游戏打下基础。

代码设计上,Halcyon也做了很多大胆尝试,比如尽量少的设计“样板代码”(boilerplate code)。而Halcyon利用lambda再加上一些宏的“魔法”,让Pass的代码编写十分简洁明了也更加方便快速迭代。同时他们也计划利用Rust实现一套代码生成器来适应他们设计的DSL。(这里岔开一句,琨少在PipelineSubPass.h里设计的写法跟Halcyon挺像的,不知道什么时候可以用上)。另外的话因为Halcyon并不是一个为了正式上线项目设计的,对于性能和易用性方面没有太严苛的要求,所以也会尽可能少的造轮子,已有的开源轮子能用就用,例如glm、ImGui等等。

overall.png

Render Backend

渲染是游戏的一个非常重要的部分,也是近年来前沿技术比较多的方面。但是不同的平台和硬件会有不同的API接口,所以需要针对每个平台实现一个对应的渲染后端。由于不同的渲染后端也会有一些实现细节的差异,所以一般都会选取一套API作为主要的设计参考抽象出引擎的RHI,然后在其他API上做好适配。RHI的抽象目的是让上层逻辑可以对当前使用的渲染设备完全不关心,同时提供统一的接口进行调度。Halcyon在设计RHI模块的时候,主要参考的是DX12,然后实现中做到以下几个方面:

  • 实现渲染后端的DLL可以动态加载和卸载,意味着可以方便的切换渲染后端(让我想到小时候玩的CS可以不重启切换software、OpenGL、D3D)
  • 每个渲染后端模块内封装好physical device的选择(IDXGIAdapter1VkPhysicalDeviceMTLDevice),logic device的创建和销毁的逻辑(Render Device,下面会提到)
  • 每个渲染后端模块内封装好swap chain相关的逻辑
  • 每个渲染后端模块内封装好extension的支持处理,比如ray tracingsub groups
  • 每个渲染后端模块内封装好debugging和profiling相关的逻辑,比如vulkan的validation layer

Halcyon实现了一共五个渲染后端,

  • DX12、Vulkan1.1、Metal2
    这三个是大家都知道的现代图形API,它们共同的特点都是把驱动层变得更薄,给开发者开放了更多的底层细节,支持多线程对多核CPU更加友好。但是在Halcyon的介绍里对于这几个后端的封装的细节介绍的很少,也就没法做更多的分析了。但是ppt中提到了,Halcyon是GPU driven的pipeline,所以会需要后端提供GPGPU的功能,同时也支持compute shader以及shader中的多线程特性,例如DX12的Shader Model 6、Wave-ops,Vulkan的sub-groups等等。另外光线追踪的引入也是Halcyon的一个重要尝试方向(但是也没有提到细节),所以DXRVK_KHR_ray_tracing扩展的调用也在Halcyon后端中封装掉了。最后就是对多个GPU的支持,也是Halcyon实现了的一个值得去研究的方向。

render_backend.png

  • Proxy
    Proxy是Halcyon为了实现云端渲染而实现的一个渲染后端。首先场景的状态都还是在客户端本地,只有渲染相关的调用会发送到云端渲染服务器。提到的一个细节是基于google的gRPC实现的,把本地的Render模块的调用命令发送到云端。这样也顺便解决了客户端GPU算力不够的问题。而这样带来的好处是可以在任意的系统上运行想要的图形API,例如在macOS上跑DX12,甚至是在Web浏览器里运行游戏(前提是除了渲染之外的其他模块都能正常跑起来)。

proxy_backend.png

  • Mock
    Mock顾名思义就是一个冒牌的假的渲染后端,并不会做任何渲染的事情,但是也不是完全没有存在意义。主要的作用是做资源的跟踪检查,渲染命令的序列化检查,总而言之就是各种不涉及图形API调用的逻辑调试和分析。

mock_backend.png

Render Device

Render Device也是引擎渲染模块实现的一个非常重要的部分,它封装抽象了平台相关的Device(VkDeviceID3D12DeviceMTLDevice),这也就意味着,Render Device需要完成GPU资源的创建和销毁,Halcyon在Render Device中还实现了资源的生命周期管理和资源和句柄的转换。Halcyon的资源句柄也是有一些设计特色的,后面会提到。另外Render Device当然也封装抽象了GPU command queue、command list的创建和提交。

render_device.png

Render Handles

Halcyon中的所有GPU资源都是用句柄(Handle)来引用。Render Handles有以下一些特性:

  • 每个GPU资源都会对应一个Handle,并且通过Handle来查询到对应的Resource的时间复杂度是O(1)
  • Handle的大小是64bits
  • 从Handle可以确定资源类型,是texture还是buffer等等
  • Handle是可以序列化和反序列化的,可以适应Proxy render backend
  • 因为Halcyon支持多个GPU,资源可能会同时存在在多个Device上,所以Handle和Resource的对应关系是一对多,并且需要保证Handle在同一个Device上是唯一的,而且可以通过Handle在Device上查询某个Resource是否存在
  • Handle是由上层应用来管理的,而真正的Resource是可以重新加载到Device的,这样也就可以动态加载和卸载Render Device和GPU
  • 另外很厉害的是Handle与使用的Backend类型无关,并且可以混用,也就是说一个Texture Handle,可能同时指向一个跑DX12 Backend的GPU上的一个ID3D12Resource和跑Vulkan Backend的GPU上的一个VkImage,这也让使用多个GPU,不同backend同时渲染成为可能(左半边屏幕DX12,右半边屏幕Vulkan)

render_handle.png

Render Commands

Halcyon抽象了许多command,大致可以分为以下几个类别,每个类别又会有功能不同的各个command。

  • Draw
  • Dispath
  • Update
  • Copy
  • RayTrace
  • Barriers

command是和command queue相关联的,每个command都指定了适用于那类queue,这样可以防止出现把queue不能执行的command放进去的操作,比如把Draw的command放进了compute queue。同时这样也有助于来自动的调度command(这里不是很明白)。 command list的设计也比较有特点,它的目的是把抽象的command转换成渲染后端的API调用。首先command list不保存状态,所以可以并行的添加command。其次是command list是有compile阶段的,compile阶段就是把High level的command转换成Low level的API接口调用,对于同样的command list,只需要一次compile,就可以进行多次的提交。但是compile的过程是一个个command串行的,这样的目的是为了过滤掉一些不必要的API调用。

render_command.png

Render Graph

同是EA的杰作,Render Graph是基于Frame Graph的改进版。Frame Graph的提出,最大的一个目的是要自动的处理临时资源减少显存的消耗,这一点在Render Graph中也被完美的继承了。一个根本的区别就是名字不同。这不是玩笑。之所以叫Render Graph是因为没有了Frame的概念,所有的资源transition和barrier都是自动的。并且可以存在多个graph以不同的更新频率来运行,这些graph可以在同一个GPU内异步的计算,也可以被分配到不同的GPU上完全并行的计算,还可以完全没有GPU的概念,例如交给服务器集群来计算然后在把结果传回。这些都是基于上面提到的Device、Handle、Command的良好的抽象和可序列化。

在实现细节方面,也做了简单的介绍。Render Graph分为两个阶段,construction和evaluation。construction阶段会指定pass的输入和输出,这个阶段是非并行的。为什么不设计成并行的,举了个高斯模糊的例子,高斯模糊的每次迭代可能需要通过输入的RT的大小来确定输出的大小,意味着每个step互相依赖,如果并行计算反而会带来更多的消耗。而evaluation阶段是完全并行的,在这个阶段会输出逻辑抽象层的command并且会自动插入barrier和transition的command。ppt中展示了一段示例代码,可以看出来非常的简洁明了。

render_graph_pass.png

Render Graph还支持利用多个GPU显式异构计算。Halcyon通过在每个GPU内存放冗余数据(meshed、textures)来尽量减少资源的拷贝也就能尽可能减少带宽的消耗,他们测试的结果是拷贝15M的数据大概需要1ms。然后把渲染屏幕按照宽度来切分成多个分区,然后每个GPU渲染一个分区。有一个primary device,其他的都是secondary device。渲染队列会被自动的准备好,数据的拷贝命令也会自动的插入,所有secondary device执行完之后,会把结果拷贝回primary device,进行组合和后处理之后就得到了最终需要的一帧画面。这个过程中pass是不用知道有多少个GPU,但是可以指定pass的schedule policy。比如指定需要是可以运行在所有device还是只有primary或者secondary device可以运行。同时可以告诉Render Graph如何传输数据,例如把partition结果拷贝给primary device。ppt中展示了一段示例代码,加入了multi-GPU的支持也还是非常的简洁明了。

multi_gpu_pass.png

Render Graph在设计之初的时候和Frame Graph一样是需要显式的连接输入和输出的。这样会带来一个问题,就是有时候pass的输入是可选的或者说是需要判断输入是否存在的(有点像messiah中AddIfInputNode这个函数?),比如要通过是否打开了PreZPass,是否输入了Depth RT,来判断是否需要disable depth,是否需要clear depth。Halcyon提出了Scope的概念来代替显式的指定输入。这样就可以更方便的跨多个pass传递数据,前面的pass只需要把结果放到scope内,后面的pass去scope里面找是否有这个数据。scope是通过类型来查找的,支持的是POD结构,像Render Handle、float、int这种都是可以封装到结构里来传递的。

render_graph_scope.png

Render Graph还有许多方便迭代和调试的特性,例如有自己的DSL,虽然当时的版本是用C++宏实现,但是计划用Rust编写一个针对它的代码生成器。另外Render Graph还有一个基于ImGui的可视化的调试界面,可以清楚的查看Pass的执行顺序和每个Pass的GPU耗时,还可以查看每个Pass的输入和输出资源和这些资源的版本信息。而且这些调试也都支持多个GPU的情景。

render_graph_dsl.png

Virtual Multi-GPU

Halcyon支持同时使用多个GPU进行渲染,但是他们在开发的时候的开发机并不都是有多个GPU的,如何调试Multi-GPU的功能就变成了一个问题。对此他们做了一个Virtual Multi-GPU的方案。大致是可以在一个adapter上创建多个device,然后实现了一个对应表,把创建的多个Virtual Device对应到实际的Adater上。这个模块存在的目的纯粹是为了调试,并且因为Adapter的实际过程还是会顺序的去完成在上面创建的device的任务,所以性能会降低一些。

virtual_multi_gpu.png

Asset Pipelines

每个引擎都会有自己的资源处理方式,一般来说都是美术在DCC工具里做好设计,然后通过插件或者中间文件格式导入到引擎中,得到引擎可以读取的格式文件,再在编辑器里把这些资源做一些整合得到类似关卡、prefab、particle这种引擎格式文件,最后把这些中间上传到VCS。这个过程中资源都会有一个唯一标识,有些引擎是用相对路径,或者是像messiah一样每个资源都有一个GUID。

Halcyon用的是文件的哈希值,可以理解为标识就是资源文件的类似sha256的hash。然后资源通常会依赖一些其他的资源,引擎会维护一个deps列表,记录依赖的资源的唯一ID,而Halcyon是引入了区块链的模式,使用了merkle tree这个结构,也就是说每个资源的任何文件内容变化都会让这个资源本身和引用了这个资源的资源的hash值变化。同时因为merkle tree,可以快速的直接查询引用关系。另外还有一个优势就是,对于网络传输,只需要传送文件内容就可以了,减少了传输类似文件名、文件大小、md5这种冗余数据。

asset_pipeline.png

另外Halcyon的资源处理流程也很特殊,整个流程都是部署在Google Cloud Platform的docker中,虽然没有详细介绍,但是我脑补的是美术在DCC里面做完之后点一下按钮就会直接上传到GCP,然后经过处理得到一个引擎可读的保存在云端的文件,然后在编辑器或者游戏中,也可以直接从云端加载文件,如果是流式渲染的话,甚至资源文件都不用取回本地。这样有许多好处,其中一个是可以做出一个很炫酷的资源监控平台(手动狗头)。

asset_pipeline_gcp.png

Shader

由于Halcyon是GPU Driven的渲染管线,所以大量的shader是compute shader。上面提到过,Halcyon支持Shader Model 6.x,也就带来很多GPU多线程的特性,比如wave-ops、sub-groups等等。另外为了性能考虑,Halcyon的shader不支持反射,需要手动绑定。

shader.png

在shader参数方面,Halcyon抽象了一个shader arguments的概念,并且定义了每个shader最多4个(可配置)shader arguments。每个shader arguments对应的是HLSL中的register space,而shader arguments可以包含SRV和UAV的resource handles和constant buffer以及offset参数。同样为了性能考虑,Halcyon的constant buffer是动态指定的,也就是说在初始化的时候会申请几个很大的buffer,并且使用的是Root CBV,然后在运行时的时候传递GPU virtual address。这样的好处是可以减少临时Descriptor。

shader_argument.png

总结

Halcyon是一个大胆尝试,使用了很多近年来新推出的显卡新特性和渲染API的新接口,还为多个GPU、云游戏提供了支持。如果把讨论的范围限制在手游领域,Halcyon能让我们学习和借鉴的还是挺少的,手机上暂时没有出现多个GPU的情况,光线追踪也没有实现,云平台倒是有了雏形。我个人觉得在Halcyon的分享中,最有研究价值的还是Render Graph的概念吧,但相对于之前Frostbite介绍过的Frame Graph,Render Graph的提升更多在于对多个GPU的支持。