Unreal渲染源码简读(一)RHI/Shader
技术干货 2025.05.23

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联系我们。