RHI作为Unreal的一个跨平台渲染封装, 存在于上层显示和下层图形api之间, 即一个中间接口层。本篇内容将通过draw call的执行过程,认识到Unreal的封装方法,以及shader与Fshader的具体应用。
一、认识draw call的执行过程
1.1
复习渲染管线
首先一起来复习一下渲染管线的知识。如图1.1.1,是GPU的渲染管线执行流程。
图1.1.1 渲染管线执行流程
一个draw call的产生,由以上的流程所生成的。这是一个通用的排布, 我们也可以根据所需进行局部调整。
在这个管线执行过程前, 即在cpu给gpu发送指令前,除了具体的数据外,传入的参数的数据格式,也非常重要, 下面会提及。
1.2
以d3d的角度理解管线装配过程
我们以龙书里的d3d代码段为案例,去理解管线装配的过程。
d3d提供一个pipelineState,用来创建管线的状态配置。如果要执行不同的shader代码,就要用不同的配置,需要创建不同的pipelineState。即告诉api,我们要用哪个配置项,执行哪个shader。
图1.2.1 管线的状态设置
我们可以看到,是先申请内存,然后再去填充各种状态。其中.VS和.PS就是把编译好的shader代码放进去,这里通过指针和size给到对应的指定。
而pRootSignature就是规定好代码的输入参数数据格式,如下图1.2.2所示。
图1.2.2 pRootSignature的输入参数数据格式
构造完成之后, 就用CD3DX12_ROOT_SIGNATURE_DESC打包成根签名描述, 再用它去创建一个真正的根签名,即上面的mRootSignature。
东西准备完了那就要draw了。从图1.2.3中可以看到,之前创建的mOpaquePSO和 mRootSignature就被放进去了。
图1.2.3 渲染执行
渲染管线状态和根签名传完以后,那么就可以传入真正的参数了,本质上就是拿虚拟地址,给到显存映射好。
图1.2.4 渲染内容设置
另外,IASetVertexBuffers传入顶点属性,SetGraphicsRootDescriptorTable传入贴图等实际内容填充,都在这里执行。在搞定所有内容之后,只需要再执行 DrawIndexedInstanced就可以让gpu执行了。
图1.2.5 渲染内容填充
二、Unreal的封装方法
接下来我们回归本篇内容的核心——在Unreal中要怎么去封装上述D3D以及其他API的渲染过程 (限于篇幅这里只提及部分内容封装)
2.1
资源封装
我们先从资源开始,所有参数的设置状态, 本质都是一段内存或者显存, 就是d3d的resource, 或者OpenGL的无符号整数resource id。
Unreal提供了一个FRHIResource , 实际内容可以是uniform buffer,也可以是texture,类型可以通过它的ERHIResourceType 枚举变量查看到, 如图2.1.1。
图2.1.1 资源类型
这个FRHIResource基类里没定义太多的操作,只有有AddRef,Release和原子操作。
我们往子类看,来到FRHIUniformBuffer,这里的封装是出于一个与平台无关的的状态,只是定义了对外的接口。接下来我们就来具体讲解它的使用方法。
图2.1.3 FRHIUniformBuffer
接下来我们再子类看, 来到FD3D12UniformBuffer。
图2.1.4 FD3D12UniformBuffer
来到熟悉的FD3D12ResourceLocation,我们进去看看。
图2.1.5 FD3D12ResourceLocation里的内容
可以看到有FD3D12Resource变量。
图2.1.6 FD3D12Resource
最终找到了d3d12里真正存储resource的ID3D12Resource指针。这个location里面有一个成员变量,又是一个FD3D12Resource。而这个FD3D12Resource里面,就是真正D3D12的指针。当然,下面还有一些别的被它封装了起来,比如GPUVirtualAddress。
所以这就是一套简单的继承体系,我们想用什么接口,就在RHI层面定义一个接口,在子类里去实现它。
接口就是通用的, 比如Create、Release一段资源,或者Upload一段内存进去。
同理OpenGL那边也就会有一个FOpenGLUniformBuffer。
图2.1.7 FOpenGLUniformBuffer
UE RHI只做一些命名和通用接口封装, 实现都是在不同的子类, 编译到哪种平台就用对应的上层逻辑代码, 只需要关注操作的是RHI层的东西就可以, 直接调用已经封装好的接口就行。
接下来我们以Unreal里最常用的UTexture2D举个例子:
图2.1.8 UTexture2D
比如让它执行UpdateTextureRegions, 它实际上就是去调RHI的函数。
图2.1.9 UpdateTextureRegions
图2.1.10 执行更新
2.2
执行封装
这里就不得不提到这里出现的FDynamicRHI了。FDynamicRHI是RHI的动态实现部分,而RHI是更底层的静态接口。这个类里的方法超多,比如常见的图形渲染操作,创建纹理、状态、更新资源、设置Fence、更新贴图等一大半操作,都在此类中。但都没实现。OpenGL、D3D会去继承它、实现它。所以不要在上层写api的实际调用内容,否则代码无法跨平台。
这里面都是些纯虚函数,同样下面有D3D和OpenGL的各种各样的子类。
图2.2.1 FDynamicRHI
三、在Unreal中封装
3.1
shader的封装
FRHI shader是继承FRHIResource, 思路也跟之前一样。其子类D3D和OpenGL会有具体且复杂的实现。
图3.1.1 FRHI shader
成员Frequency枚举,用于定义该Shader是哪个类型。
图3.1.2 成员Frequency枚举
以FRHIVertexShader为例我们来看一下, 先直接看d3d怎么做的吧。
图3.1.3 FRHIVertexShader
这里继承两个类, 后面的FD3DShaderData才是主要内容。
图3.1.4 FD3DShaderData
比较重要的就是code了, 内部将其转换成D3D的bytecode。
根据代码去创建一个shader的话, 就要用到之前提到的FDynamicRHI定义的接口,比如OpenGL的实现如下。
图3.1.5 创建shader
进入后,先会解析一下Code,因为Unreal这里的code不是纯代码,还有带有参数,所以会先用一个Reader解析。
图3.1.6 获取Optional数据大小
图3.1.7 获取实际Code大小
可以看到Code前半段是代码,后半段是Optional的数据。代码长度=总长-OptionalData长度。
解析完代码以后, 会进行一个glsl的转换, 因为OpenGL在各个平台也有适配差异, 是需要做一些适配变化的。
图3.1.8 glsl转换
最终传入代码, 并进行编译操作。
图3.1.9 输入并编译代码
d3d同理, 这里就不做阐述了。
3.2
FShader简介
FShader是上层的一个类,FRHIShader相对于自己那套继承体系是上层,但相对于FShader则是底层。有RHI的都是相对的底层或者中间层。
图3.2.1 shader创建
FGlobalShader可能大家比较熟悉,自己要写了一个hlsl的shader,那通常继承FGlobalShader, 而 FGlobalShader是继承自FShader。
图3.2.2 Unreal官方hlsl转glsl shader pipeline的示意图
所以我们写的usf并不是最终的代码,还会继续进行转换。转换过程中,就会去检测编译选项,一个usf中有不同的各种各样不同的编译选项,即排列组合permutation。根据不同的排列组合编译选项,去编译不同的shader,虽然看起来是同一份shader代码。这个转换过程十分复杂,不必深究,我们知道如果出现permutation之类的编译选项,则同一份shader代码可能编译出不同的shader即可。
3.3
FShader怎么做反射
FShader没有像Unreal其他UObject类的反射,用UPROPERTY就拿到它的反射,而是通过DECLARE_TYPE_LAYOUT宏,单独实现的一套反射机制。
图3.3.1 反射宏
比如这里FShader的成员变量, 只要加了这个LAYOUT_XXX,就有反射信息。它是怎么做到的呢, 我们展开一个LAYOUT_FIELD 宏。
图3.3.2 宏展开
首先定义Bindings成员变量本身,然后定义了一个新的模板类InternalLinkType,即记录类型, 和用offsetof拿到偏移量。
最终达到的目的是:只要拿到FShader的指针,加上这个Offset后,就拿到了这个Bindings成员变量的地址,再进行到FShaderParameterBindings的内存转换,就能拿到这段内存了。
LAYOUT_FIELD 的信息收集,就是收集Name、收集成员变量的指针、收集其他信息。
总结
本篇内容介绍了Unreal 的封装方法,讲解了在Unreal中如何实现shader的封装以及Fshader的反射。如果想了解更多关于封装调用,形成完整渲染pass的信息,欢迎发送邮件至mkt@eptcom.com联系我们。