游戏引擎随笔 0x03:可扩展的渲染管线架构
渲染管线是游戏引擎完成一帧画面的高层渲染逻辑和流程,它包括引擎内置的渲染特性,渲染路径以及相应的渲染资源管理等。一般来说,游戏引擎的渲染管线基本都是固定在引擎内部的,比如目前主流的 Forward 和 Deferred 两种渲染管线,以及一些常用的渲染特性(HDR、DOF、SSAO、SSR、5S 等),几乎都是硬编码在引擎的渲染核心中。但近些年来,这种内置固化的渲染管线架构已经越来越不能满足行业发展的需求,具体表现在以下几个方面:1.多样的游戏画面表现需求
不同的游戏在渲染上的风格可能不尽相同,有的写实,有的卡通,有的更加风格化,这就要求引擎的渲染管线能具有相应的可调整的能力,否则就会出现千游一面的窘境,比如在上个世代,看画面就能知道是哪个引擎做的了。实际上这种情况到目前这个世代,也没有好转多少。
2.不同平台的软硬件能力差异
对于跨平台引擎来说,不同平台的硬件和软件能力,对引擎的渲染管线架构也提出了多样性的需求。比如 PC、主机硬件能力更强,需要有更高端的画面表现,试图通过一种渲染流程来充分发挥每个平台的优势以及消除劣势,是一件非常困难的事情,即使做到了,也在代码中通过条件编译和路径选择来区分不同平台的渲染逻辑,这会导致渲染管线的代码越来越难以维护。对于移动平台来说,由于硬件、软件(图形 API)的限制,不可能达到 PC 和主机相同的画质,并且由于 CPU\GPU 架构不同,甚至是要采用与 PC 架构完全不同的渲染逻辑,才能充分发挥移动平台的性能。而这些问题都要通过实现不同的渲染管线才能彻底解决。
3.Rendering Path 的不断进化
此外,Rendering Path 也在不断地进化,从最传统的 Forward Shading,到上世代过渡技术的 Light Pre-Pass,到彻底的 Deferred Shading,以及一系列的 Deferred 变种:Tiled Deferred Shading、Clustered Deferred Shading。还有 Forward 的变种:Forward+、Clustered Forward Shading 等等。这些 Rendering Path 不同的执行逻辑,也必须要对应不同的渲染管线架构。
4.新的渲染技术
近年来的 VR\AR\XR 渲染技术对渲染管线提出了特定的需求,一套管线已经很难做到完全兼容这些渲染机制。在 2018 年微软发布了革命性的 DirectX RayTracing,这更是完全不同 Rendering\Computing 机制。举例来说,对于传统光栅化,只需要部分的场景信息(可见物体)即可,甚至对同一个物体而言也只需要面向相机的信息即可。而对于 DXR,管线需要知道整个的场景信息,才能计算出 GI 中的 Indirect Lighting、AO、Reflection 等数据。这样一来,传统的光栅化管线的可视化裁剪步骤,对于 DXR 来则可能完全不需要了。另外,由于目前 DXR 还在不断完善过程中,现阶段还是以光栅化为主,DXR 为辅的混合方式来完成渲染。这种些渲染技术的变化,导致渲染管线架构不可避免要做出调整。
通过上述几点,说明传统的固定功能的渲染管线架构已经不再适合未来游戏发展的需求,如果重新设计引擎的渲染管线,必须要考虑到渲染管线的可扩展性、可配置性、甚至是 Data-Driven 的。
主流游戏引擎的现状
修正更新:感谢 @call draw 的评论提醒,我去查了一下,UE 4.22 版本中已经有一个 Render Graph 框架,目前还在不断完善的过程中,刚兴趣的可以查看源码。下面是之前的原文:
先说下 Unreal Engine 4,在目前对外公开的 RoadMap 中,UE4 还没有可扩展渲染管线的开发计划,原因可能是 UE4 的渲染管线内部耦合太紧密,修改的成本太高。尽管 UE4 提供了引擎核心所有的源码,有实力的开发者在充分了解引擎核心渲染机制的情况下是可以实现自己的渲染管线,但很明显这对开发者并不友好,试问有几个团队真的有这个能力?更何况随着引擎版本的升级,魔改的渲染管线无法兼容新版本,进而无法享受到新版本所带来的好处。我了解到目前有两个游戏引擎提供了可扩展渲染管线的解决方案,一个是 EA 内部的公共引擎 Frostbite,在 2017 年提出了可扩展的渲染管线方案:Frame Graph,另一个是 Unity,在 2017 年提出了 Scriptable Render Pipeline,用以扩展和自定义渲染管线。下面就以 Frostbite 和 Unity 两个引擎为例进行简单的讨论,如果想知道更具体的细节请自行到上面给出的链接,在此不再赘述。
Frostbite:Frame Graph(FG)
FG 由 RenderPass 以及其依赖的 Resource 组成。RenderPass 定义了一个完整的渲染操作,Resource 包括了 RenderPass 使用的 PSO、Texture、RenderTarget、ConstantBuffer、Shader 等资源。每个 RenderPass 都有 Input 和 Output 资源,这样 RenderPass 和 Resource 就形成了有向非循环图(DAG)结构,因为描述的是引擎在一帧内的渲染流程,所以称之为 Frame Graph,下图是 FG 的一个例子:
FG 在渲染逻辑的高层执行引擎的渲染功能
此外,FG 还包括对资源的调度管理,跟踪资源的生命周期,降低资源的消耗。由于是图的形式,因此可以很容易以可视化的方式呈现一帧内的渲染流程,还可以观察每个 RenderPass 依赖的资源,或者反过来看每个资源都被哪些 RenderPass 所使用。
Frostbite 引擎作为 EA 所有工作室的通用引擎,无需考虑授权问题,可以直接通过 C++ 方式提供渲染管线的扩展能力,这样就可以无障碍的访问引擎内部渲染接口和数据,无疑是非常方便的。
Unity:Scriptable Render Pipeline(SRP)
Unity 并没有使用 FG 的概念,而是通过直接暴露引擎渲染核心接口的方式提供可扩展的能力。这称之为 SRP。2018.4 版本 Rendering 的 Classes 只有十几个,甚至都没有 RenderPipeline 这个类,而到了 2019.1 版本,这个数字扩张了 3 倍,将近 50 个 Classes,新的接口包括:RenderPipeline、ScriptableRenderContext、ScriptableCullingParameters 等等,这些新的接口几乎都是为 SRP 提供基础服务支持的。在 SRP 基础上,Unity 提供了官方的 2 条自定义管线:HDRP 和 LWRP,分别用于高端渲染和低端渲染,高端对应 PC\主机平台,低端对应移动平台。而且如果开发者有能力的话可以创建满足自己要求的自定义管线。
Unity 的引擎核心 C++ 源码需要特定授权才能获取,仅仅通过 C++ 方式提供可扩展机制无法满足广大没有引擎源码授权的开发者,于是 Unity 提供了通过 C# 脚本扩展渲染管线能力,这样任何使用 Unity 引擎开发者都可以定制自己的 Rendering Pipeline,这也是命名为 "Scriptable" 的主要原因。通过 C# 脚本而非 C++ 方式扩展渲染管线,对开发者来说更加友好,难度也会更低些。而且可以充分利用脚本优势,比如快速迭代、编辑后立即生效、可调试等等。
Unity 虽然在 2017 年就提出了 SRP 概念,但直到今年 2019.1 版本 SRP 才真正稳定下来合并到正式版本中。这前后花了 1 年多的时间逐步完善。由此可见这种改进对原有管线架构的修改一定是相当大的。从 Unity SRP 目前暴露的C# API 来看,还是有些“固定”的,SRP 的流程基本还是内置在引擎 C++ 逻辑中,只是在流程中的关键点上提供了脚本化的接口,和 Frostbite 引擎相比,可定制化能力要差了一些。另外为了满足更多更复杂的自定义需求,要暴露出足够多的引擎核心 C++ 渲染接口,而这需要 Unity 不断推出新版本才行。但无论怎样,与 UE4 相比,在通用商业引擎中渲染管线的可扩展方面,Unity 表现的更加先进。
我们该如何做?
对比 FG 和 SRP 这两种方案,我更倾向于 FG 的方式,这里以我理解的方式,来说明我的可扩展渲染管线的架构。
首先确定名称,我不打算采用 FrameGraph,而是命名为 RenderGraph(RG),毕竟是渲染相关,用 Render 作为前缀比 Frame 更具有统一性。
在 RG 中,每个 RenderPass(RP) 包含三个重要阶段,如下图:
RenderPass 的三个阶段
Setup 阶段,主要用于定义输入和输出所使用的资源,比如 RenderTarget、Buffer 等。<i>Compile 阶段,根据 Setup 所定义的资源,来决定真正需要执行的 RP 执行路径图,并且裁剪掉不需要执行的 RP 依赖资源。Execute 阶段,是真正执行 RP 渲染逻辑的阶段。在这个阶段可以直接调用渲染 API 将 Render Command 和 GPU 资源提交到渲染设备中,完成真正的渲染。
Setup 一般只需要执行一次,Execute 每帧都需要执行,Compile 可根据情况执行多次,比如某个 RP 根据配置动态变化,这时需要重新 Compile,以获得变化后的 RP 执行路径图。
实现
下面给出 RenderGraph 一个简单实现的伪代码,注意仅仅是接口定义,并没有具体实现细节:
// IRenderPass 接口类,定义 Setup 和 Exec 两个接口
class IRenderPass
{
protected:
string strPassName;
public:
const string& Name() const
{
return strPassName;
}
virtual void Setup() = 0;
virtual void Exec() = 0;
};
// 模板化的 RenderPass,从 IRenderPass 继承,传入 setup 和 exec 两个 lambda 模版函数参数
template <typename LAMBDA_SETUP, typename LAMBDA_EXEC>
class RenderPass : public IRenderPass
{
public:
RenderPass(const char* renderPassName,
const LAMBDA_SETUP& setup, const LAMBDA_EXEC& exec)
: func_setup(setup)
, func_exec(exec)
{
strPassName = renderPassName;
}
const char* NameCStr() const
{
return strPassName.c_str();
}
// 执行 lambdas Setup 逻辑
virtual void Setup()
{
func_setup(this);
}
// 执行 lambdas Exec 逻辑
virtual void Exec()
{
func_exec(this);
}
private:
// 定义 lambdas Setup 成员
LAMBDA_SETUP func_setup;
// 定义 lambdas Exec 成员
LAMBDA_EXEC func_exec;
};
// Render Graph
class RenderGraph
{
// 所有的 RP 列表
std::list<IRenderPass*> renderPasses;
// RP 执行路径,在此列表中的 RP 才会执行渲染逻辑
std::list<IRenderPass*> renderPassPath;
bool isCompiled;
public:
// 指定名称和 setup\exec lambdas 表达式,向 RG 中加入一个 RP
template <typename LAMBDA_SETUP, typename LAMBDA_EXEC>
void AddRenderPass(const char* renderPassName,
const LAMBDA_SETUP& setup, const LAMBDA_EXEC& exec)
{
auto rp = new RenderPass<LAMBDA_SETUP, LAMBDA_EXEC>
(renderPassName, setup, exec);
renderPasses.push_back(rp);
NeedCompile();
}
// 指定名称和 IRenderPass 对象,向 RG 中加入一个 RP
void AddRenderPass(const char* renderPassName, IRenderPass* rp)
{
renderPasses.push_back(rp);
NeedCompile();
}
// 根据名称删除 RP
void RemoveRenderPass(const char* renderPassName)
{
renderPasses.remove_if((auto rp)->bool
{
return rp->Name() == renderPassName;
});
NeedCompile();
}
// 遍历每个 RP,依次调用 Setup
void Setup()
{
for (auto rp : renderPasses)
{
rp->Setup();
}
}
// 遍历每个 RP,根据依赖资源执行裁剪,并得出 RP 路径
void Compile()
{
// 生成 renderPassPath 列表
。。。
}
// 遍历 RP 路径的每个 RP,调用 Exec 函数执行真正的渲染逻辑
void Run()
{
Compile();
for (auto rp : renderPasses)
{
rp->Exec();
}
}
void NeedCompile()
{
isCompiled = false;
renderPassPath.clear();
}
void Clear()
{
renderPasses.clear();
NeedCompile();
}
};使用时伪代码如下:
unique_ptr<RenderGraph> renderGraph = make_unique<RenderGraph>();
renderGraph->AddRenderPass( &#34;MyRenderPass1&#34;,
[](auto rp) {
// 定义使用的资源
。。。
},
[](auto rp) {
// 执行渲染逻辑
。。。
}
);
renderGraph->AddRenderPass(&#34;MyRenderPass2&#34;,
[](auto rp) {
// 定义使用的资源
。。。
},
[](auto rp) {
// 执行渲染逻辑
。。。
}
renderGraph->Setup();
renderGraph->Run();通过 Render Graph,我们可以实现可扩展的渲染管线架构,外部可以通过向引擎的渲染核心设置一个 RG 对象,即可实现不同的渲染逻辑,甚至可以实现多个 RG 同时协作共同完成更复杂的渲染逻辑。
进一步的改进
由于 RG 属于渲染管线的一部分,所以 RG 应该运行在渲染主逻辑所在的线程中,而非游戏逻辑线程中。另外,RG 可以并行执行,因为每个 RP 都是无状态的,只要处理好依赖资源的关系,设置好同步点,完全可以做到并行执行。甚至每个 RP 内部也可以是并行的,比如 Culling 等渲染操作。这样对天生具有并行能力的现代图形 API 如 D3D12\Vulkan\Metal 来说是相当友好的,可以充分发挥了软硬件系统的优势。
目前 RG 的实现方案是 Code-Driven 的,未来是可以扩展成为可配置的,甚至是 Data-Driven 的。如果引擎有可视化编程机制,甚至可以实现可视化的方式自定义渲染管线,这对开发者来说将会更加直观、方便。
页:
[1]