前言
这是我在学习 Unreal 渲染依赖图(RDG)过程中的踩坑与总结笔记(一),本篇主要记录在虚幻引擎中调用计算着色器的实践过程。
Unreal版本为5.7。
配置Unreal工程
创建Unreal工程
创建一个空白C++虚幻工程,将项目名称设置为LearnRDG。
配置uproject
打开工程文件夹下的LearnRDG.uproject,将Modules内的LoadingPharse从Default修改为PostConfigInit。
1 2 3 4 5 6 7 8 9 10 11 "Modules" : [ { "Name" : "LearnRDG" , "Type" : "Runtime" , "LoadingPhase" : "PostConfigInit" , "AdditionalDependencies" : [ "Engine" ] } ]
修改LearnRDG.Build.cs
打开LearnRDG.Build.cs,添加RHI、RenderCore、Projects依赖。
1 2 3 4 5 6 7 8 9 10 11 12 13 using UnrealBuildTool;public class LearnRDG : ModuleRules { public LearnRDG (ReadOnlyTargetRules Target ) : base (Target ) { PCHUsage = PCHUsageMode.UseExplicitOrSharedPCHs; PublicDependencyModuleNames.AddRange(new string [] { "Core" , "CoreUObject" , "Engine" , "InputCore" }); PrivateDependencyModuleNames.AddRange(new string [] { "RHI" , "RenderCore" , "Projects" }); } }
修改LearnRDG.cpp
官方文档中提到,Unreal 的着色器文件(.usf / .ush)通常存放在 Engine/Shaders 和 Engine/Plugins 目录下。
为了便于管理,我们希望将自定义着色器放置在工程目录中,因此首先在工程根目录下创建一个 Shaders 文件夹。
1 2 3 4 LearnRDG |- Content |- Sources |- Shaders
随后打开LearnRDG.cpp文件,将内容替换为
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 #include "LearnRDG.h" #include "Modules/ModuleManager.h" #include "Misc/Paths.h" #include "ShaderCore.h" class FLearnRDGGameModule : public FDefaultGameModuleImpl {public : virtual void StartupModule () override { const FString ShaderDir = FPaths::Combine (FPaths::ProjectDir (), TEXT ("Shaders" )); AddShaderSourceDirectoryMapping (TEXT ("/MyShaders" ), ShaderDir); } virtual void ShutdownModule () override { } };IMPLEMENT_PRIMARY_GAME_MODULE (FLearnRDGGameModule, LearnRDG, "LearnRDG" );
在 FLearnRDGGameModule 的 StartupModule() 中,通过相关代码构建虚拟路径映射。完成映射后,即可使用 /MyShaders 来指代工程目录下的 /Shaders 文件夹,从而像使用引擎内置着色器路径一样引用自定义着色器。
关闭虚幻编辑器,编译Visual Studio工程。
一定要关闭编辑器后再编译,不然会遇到许多莫名其妙的问题(后续每次编译都需要先关闭虚幻编译器)。
在 UE 中使用 RDG 构建计算管线
编写计算着色器的usf文件
在工程目录下的Shaders创建一个名为ComputeShader.usf的文件,编写以下代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 #include "/Engine/Public/Platform.ush" float Scale;float Translate; StructuredBuffer<float > InputBuffer; RWStructuredBuffer<float > OutputBuffer; [numthreads (1 , 1 , 1 )]void MainCS ( uint3 DispatchThreadId : SV_DispatchThreadID, uint GroupIndex : SV_GroupIndex) { OutputBuffer[DispatchThreadId.x] = InputBuffer[DispatchThreadId.x] * Scale + Translate; }
(示例计算着色器来自:Unreal Shader Tutorial - Chinese Ver. | SirEnri’s Homepage )
创建计算着色器的Global Shader
打开UE编辑器,选择 工具->新建C++文件,父类选择 无 ,命名为LearnRDGShader。
删除LearnRDGShader.h中#include "CoreMinimal.h"以下的所有代码,删除LearnRDGShader.cpp中的所有代码。
在LearnRDGShader.cpp中编写以下代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 #include "LearnRDGShader.h" #include "GlobalShader.h" #include "RenderGraphBuilder.h" #include "ShaderParameterStruct.h" #include "RenderGraphDefinitions.h" #include "RenderGraphUtils.h" #include "RHIGPUReadback.h" #include "ScreenPass.h" #include "PixelShaderUtils.h" class LEARNRDG_API FTestCS : public FGlobalShader {public : DECLARE_GLOBAL_SHADER (FTestCS); SHADER_USE_PARAMETER_STRUCT (FTestCS, FGlobalShader); BEGIN_SHADER_PARAMETER_STRUCT (FParameters, ) SHADER_PARAMETER (float , Scale) SHADER_PARAMETER (float , Translate) SHADER_PARAMETER_RDG_BUFFER_SRV (StructuredBuffer<float >, InputBuffer) SHADER_PARAMETER_RDG_BUFFER_UAV (RWStructuredBuffer<float >, OutputBuffer) END_SHADER_PARAMETER_STRUCT () static bool ShouldCompilePermutation (const FGlobalShaderPermutationParameters& Parameters) { return true ; } };IMPLEMENT_GLOBAL_SHADER (FTestCS, "/MyShaders/ComputeShader.usf" , "MainCS" , SF_Compute);
着色器类的声明参考UE源码,BEGIN_SHADER_PARAMETER_STRUCT(FParameters, )内部的宏定义需要与HLSL源文件中的着色器输入 一一对应。
随后调用IMPLEMENT_GLOBAL_SHADER,把 .usf 里的计算着色器入口函数,注册成 Unreal 可识别、可编译、可使用的全局着色器类型。
EPIC官方文档:虚幻引擎中的渲染依赖图 | 虚幻引擎 5.7 文档 | Epic Developer Community
使用 FRDGBuilder 添加计算 Pass
在LearnRDGShader.h添加函数声明:
1 2 3 4 namespace LearnRDGShader { void AddTestComputePass (FRDGBuilder& GraphBuilder, float Scale, float Translate, FRHIGPUBufferReadback* Readback) ; }
其中FRDGBuilder是UE的RDG构建器,可以通过这个构建在渲染图中添加计算通道。Scale和Translate用于配置Hlsl中的参数。FRHIGPUBufferReadback用于回读计算着色器的输出结果到CPU。
在LearnRDGShader.cpp中实现函数:
1 2 void TestShader::AddTestComputePass (FRDGBuilder& GraphBuilder, float Scale, float Translate, FRHIGPUBufferReadback* Readback) {}
首先获取之前注册的计算着色器:
1 2 3 4 5 6 void TestShader::AddTestComputePass (FRDGBuilder& GraphBuilder, float Scale, float Translate, FRHIGPUBufferReadback* Readback) { TShaderMapRef<FTestCS> ComputeShader (GetGlobalShaderMap(GMaxRHIFeatureLevel)) ; }
随后需要配置计算着色器的参数,包括两个 float 类型的标量参数、一个作为输入的 SRV 缓冲区,以及一个作为输出的 UAV 缓冲区。
这些参数通过 FTestCS::FParameters 结构体进行描述,并在创建 Pass 时将对应的资源与数值绑定到该结构体实例上。
现在配置float类型的标量参数
1 2 3 4 5 6 7 8 9 void TestShader::AddTestComputePass (FRDGBuilder& GraphBuilder, float Scale, float Translate, FRHIGPUBufferReadback* Readback) { TShaderMapRef<FTestCS> ComputeShader (GetGlobalShaderMap(GMaxRHIFeatureLevel)) ; FTestCS::FParameters* PassParameters = GraphBuilder.AllocParameters <FTestCS::FParameters>(); PassParameters->Scale = Scale; PassParameters->Translate = Translate; }
配置缓冲区稍微复杂,参考UE官方文档和源码,配置步骤如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 void TestShader::AddTestComputePass (FRDGBuilder& GraphBuilder, float Scale, float Translate, FRHIGPUBufferReadback* Readback) { TShaderMapRef<FTestCS> ComputeShader (GetGlobalShaderMap(GMaxRHIFeatureLevel)) ; FTestCS::FParameters* PassParameters = GraphBuilder.AllocParameters <FTestCS::FParameters>(); PassParameters->Scale = Scale; PassParameters->Translate = Translate; constexpr uint32 BufferElementNum = 2 ; constexpr uint32 BufferSize = sizeof (float ) * BufferElementNum; TArray<float > InitialData; InitialData.Init (10.0f , BufferElementNum); FRDGBufferRef InRDGBuffer = CreateStructuredBuffer ( GraphBuilder, TEXT ("TestCS.InRDGBuffer" ), sizeof (float ), BufferElementNum, InitialData.GetData (), BufferSize ); FRDGBufferSRVRef InSRV = GraphBuilder.CreateSRV (InRDGBuffer); FRDGBufferRef OutRDGBuffer = GraphBuilder.CreateBuffer ( FRDGBufferDesc::CreateStructuredDesc (sizeof (float ), BufferElementNum), TEXT ("TestCS.OutRDGBuffer" ) ); FRDGBufferUAVRef OutUAV = GraphBuilder.CreateUAV (OutRDGBuffer); PassParameters->InputBuffer = InSRV; PassParameters->OutputBuffer = OutUAV; }
由于需要在创建计算 Pass 之前从 CPU 侧初始化 GPU 缓冲区数据,这里使用 UE 提供的辅助函数 CreateStructuredBuffer 来创建输入缓冲区 InRDGBuffer。该函数会负责创建 RDG Buffer,并将初始数据上传至 GPU。
对于输出缓冲区,由于不需要 CPU 侧提供初始数据,直接通过 FRDGBuilder 的成员函数 CreateBuffer 创建即可(实际上,CreateStructuredBuffer 的内部实现同样是基于 CreateBuffer 创建临时缓冲区)。
需要注意的是,在创建 FRDGBufferRef 之后,还需要分别创建对应的 FRDGBufferSRVRef 和 FRDGBufferUAVRef 才能在着色器中进行访问。这一设计与 Vulkan 中 VkBuffer 和 VkBufferView 的关系类似:前者表示资源本体,后者则描述了资源的具体访问方式。
随后添加计算Pass并回读数据
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 void TestShader::AddTestComputePass (FRDGBuilder& GraphBuilder, float Scale, float Translate, FRHIGPUBufferReadback* Readback) { TShaderMapRef<FTestCS> ComputeShader (GetGlobalShaderMap(GMaxRHIFeatureLevel)) ; FTestCS::FParameters* PassParameters = GraphBuilder.AllocParameters <FTestCS::FParameters>(); PassParameters->Scale = Scale; PassParameters->Translate = Translate; constexpr uint32 BufferElementNum = 2 ; constexpr uint32 BufferSize = sizeof (float ) * BufferElementNum; TArray<float > InitialData; InitialData.Init (10.0f , BufferElementNum); FRDGBufferRef InRDGBuffer = CreateStructuredBuffer ( GraphBuilder, TEXT ("TestCS.InRDGBuffer" ), sizeof (float ), BufferElementNum, InitialData.GetData (), BufferSize ); FRDGBufferSRVRef InSRV = GraphBuilder.CreateSRV (InRDGBuffer); FRDGBufferRef OutRDGBuffer = GraphBuilder.CreateBuffer ( FRDGBufferDesc::CreateStructuredDesc (sizeof (float ), BufferElementNum), TEXT ("TestCS.OutRDGBuffer" ) ); FRDGBufferUAVRef OutUAV = GraphBuilder.CreateUAV (OutRDGBuffer); PassParameters->InputBuffer = InSRV; PassParameters->OutputBuffer = OutUAV; GraphBuilder.AddPass ( RDG_EVENT_NAME ("MyAddCS" ), PassParameters, ERDGPassFlags::Compute, [PassParameters, ComputeShader, OutRDGBuffer, Readback](FRHICommandList& RHICmdList) { FComputeShaderUtils::Dispatch (RHICmdList, ComputeShader, *PassParameters, FIntVector (1 , 1 , 1 )); } ); AddEnqueueCopyPass (GraphBuilder, Readback, OutRDGBuffer, BufferSize); }
通过 FRDGBuilder 的成员函数 AddPass 向 Render Graph 中添加一个计算 Pass,并在传入的 lambda 函数中定义该 Pass 的执行逻辑。
从使用方式上看,这一过程与 Vulkan 中通过 vkCmd 系列函数向命令缓冲区提交操作较为相似,
最后,通过 UE 提供的辅助函数 AddEnqueueCopyPass 将计算结果回读到 CPU。需要注意的是,数据回读必须通过单独的 Pass 来完成,而不能直接在前面计算 Pass 的 lambda 函数中进行。
这是因为在 RDG 的构建阶段,Pass 仅用于描述执行逻辑,此时相关计算 Pass 可能尚未执行完成,资源依赖和状态尚未满足;只有通过额外的拷贝 Pass,才能确保在计算结果生成之后再进行数据回读。
在 Actor 的 BeginPlay() 中调用
新建一个 C++ 类,父类选择 Actor,并命名为 ComputeRDGHelper。
随后在 ComputeRDGHelper 类的 BeginPlay() 生命周期函数中,调用前面实现的用于向 RDG 添加计算 Pass 的函数。
ComputeRDGHelper.h的内容如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 #pragma once #include "CoreMinimal.h" #include "GameFramework/Actor.h" #include "RHIGPUReadback.h" #include "ComputeRDGHelper.generated.h" UCLASS ()class LEARNRDG_API AComputeRDGHelper : public AActor { GENERATED_BODY () public : AComputeRDGHelper ();protected : virtual void BeginPlay () override ; UPROPERTY (EditAnywhere, BlueprintReadOnly, Category = "TestShader" ) float ScaleValue = 1.0f ; UPROPERTY (EditAnywhere, BlueprintReadOnly, Category = "TestShader" ) float TranslateValue = 2.0f ; TSharedPtr<FRHIGPUBufferReadback, ESPMode::ThreadSafe> Readback;public : virtual void Tick (float DeltaTime) override ; void AddTestComputePass (float Scale, float Translate) const ; };
ComputeRDGHelper.cpp的内容如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 #include "ComputeRDGHelper.h" #include "RenderGraphBuilder.h" #include "LeranRDGShader.h" AComputeRDGHelper::AComputeRDGHelper () { PrimaryActorTick.bCanEverTick = true ; }void AComputeRDGHelper::BeginPlay () { Super::BeginPlay (); check (GEngine != nullptr ); GEngine->AddOnScreenDebugMessage (-1 , 5.0f , FColor::Yellow, TEXT ("BeginPlay()!" )); Readback = MakeShared <FRHIGPUBufferReadback>(TEXT ("MyAddReadback" )); AddTestComputePass (ScaleValue, TranslateValue); }void AComputeRDGHelper::Tick (float DeltaTime) { Super::Tick (DeltaTime); if (!Readback.IsValid ()) return ; if (!Readback->IsReady ()) return ; TSharedPtr<FRHIGPUBufferReadback, ESPMode::ThreadSafe> Local = Readback; Readback.Reset (); ENQUEUE_RENDER_COMMAND (PollReadback)( [Local](FRHICommandListImmediate& RHICmdList) { const uint32 NumBytes = sizeof (float ) * 2 ; const void * DataPtr = Local->Lock (NumBytes); const float * Values = static_cast <const float *>(DataPtr); const float A = Values[0 ]; const float B = Values[1 ]; Local->Unlock (); AsyncTask (ENamedThreads::GameThread, [A, B]() { UE_LOG (LogTemp, Log, TEXT ("Readback: %f %f" ), A, B); }); } ); }void AComputeRDGHelper::AddTestComputePass (float Scale, float Translate) const { GEngine->AddOnScreenDebugMessage (-1 , 5.0f , FColor::Yellow, TEXT ("AddTestComputePass" )); ENQUEUE_RENDER_COMMAND (AddComputePass)( [Scale, Translate, ReadbackPtr = Readback.Get ()](FRHICommandListImmediate& RHICmdList) { FRDGBuilder GraphBuilder (RHICmdList); TestShader::AddTestComputePass (GraphBuilder, Scale, Translate, ReadbackPtr); GraphBuilder.Execute (); } ); GEngine->AddOnScreenDebugMessage (-1 , 5.0f , FColor::Yellow, TEXT ("AddTestComputePassFinish" )); }
与 RDG 相关的操作必须在渲染线程Render Thread 中执行,如果在游戏线程Game Thread 中直接调用,将导致程序崩溃。因此需要使用 ENQUEUE_RENDER_COMMAND() 将相关逻辑封装并提交到渲染线程执行。
在编辑器中创建蓝图
为刚刚编写的 C++ 类创建对应的 UE 蓝图,将其拖入场景中并点击 Simulate 进行运行。
随后查看输出目录,可以发现计算结果已成功从 GPU 回读到 CPU。