前言
什么是ComputeShader
众所周知,游戏中大部分的GamePlay逻辑都是运行在CPU的,而渲染相关逻辑一般运行在GPU,这主要是由于CPU与GPU之间的设计差异决定的,ComputeShader(后续简称CS)就是为了让我们可以运行一部分自定义的代码在GPU,而不是一定要拘束于渲染逻辑的规范,并且引擎为我们提供了接口,可以让CPU和GPU使用一块共享的内存Buffer来共享数据。
为什么使用ComputeShader
CPU趋向于更强的单核,而GPU更趋向于大量低能的单核。所以在处理比如大规模军队,全地图的风,液体流动等等大量规范化的数据时,如果使用CPU将会导致非常大的性能瓶颈,而推给GPU的话,就会因为巨量的线程而快速完成。

目标
实现使用GPU生成一张地形图,并传回CPU,支持主线程逻辑读取地形相关数据。
注意
之前最早尝试CS是在Unity,使用起来还是非常简单的,基本就是创建一个Buffer,传给GPU,然后运行CS就可以了,但是最近项目是基于UE做的,趟了很多坑。
实际上UE自己是有官方文档的,但是不知道是因为疏于维护还是CS相关接口更新太频繁,明明文档版本我选择的就是5.6,有很多接口还是已经不存在了,而且有很多隐藏条件也没有标注,只能不断自己尝试和琢磨,所以推荐读者一定要使用和我接近的版本,即UE5.6。
再就是本文默认读者有基础的代码知识以及游戏开发能力,所以过于基础和显而易见的细节就不明确点出了。
并且由于本文主要是关于CS在UE中的使用,所以关于CS的原理相关内容,可以参考网络中的其他非常多优秀的文章。
开发
名词解释
不一定用得上,但是方便看文档
渲染依赖图(Render Dependency Graph)-RDG
我的理解就是对应Unity里的Compute Shader,指的就是UE提供的CPU和GPU交互计算的这一整套流水线
渲染硬件接口(Render Hardware Interface)-RHI
高层逻辑使用的平台无关的渲染接口,UE中与GPU交互都要通过RHI
统一缓冲区(Uniform Buffer)-UB
其实就是相当于你可以自定义一个结构体,作为着色器参数传给GPU,这类struct就起了个新名字叫统一缓冲区
静态统一缓冲区(Static Uniform Buffer)-SUB
似乎是每次设置PSO都会导致一次渲染管线内的所有阶段的着色器全都重新设置参数,而一般的统一缓冲区是直接存在参数里的,所以会导致PSO的重新设置,SUB就是为了解决这种情况,似乎是会直接把缓冲区绑定到RHI命令列表的静态插槽,而不单独提供给每个着色器,所以修改静态统一缓冲区不会导致参数的重新设置,而是着色器取用时来静态插槽中取用
管线状态对象(Pipeline State Object)-PSO
似乎是RHI调用到GPU着色之前的一个对象,用来打包一个管线内所有阶段的着色器和对应的着色器参数的
渲染图生成器(Render Graph Builder)-RDGBuilder
RDG推荐的运行方式就是使用RDGBuilder创建并执行,可以理解为RDG全流程的整体管理包装类
非顺序访问视图(Unordered Access Views)-UAV
由于computer Shader并行地处理数据,写入和输出是无序的,所以需要使用这种特殊的视图
CS是如何在UE中存在的
首先需要认知的是,在UE中,游戏运行有两个比较核心的线程,一个是主线程(Game Thread)一个是渲染线程(Render Thread),大多数情况下,我们开发的GamePlay逻辑都是在主线程执行的,而使用CS就需要使用渲染线程。
在UE中,主线程和渲染线程的结构大多是一一对应的,Shader相关的也不例外,所以在UE中我们不仅需要用于编译并在GPU运行的usf文件(对标unity的shader文件),还需要在C++实现一个与usf文件对应的Class,来在GameThread实现与usf的一一对应。
而CS在UE中,就是作为一种特殊的Shader类型存在,与顶点和片元着色器本身的逻辑是差不多的,只是有一些细微的区别,比如UAV等等。
Shader类及调用
首先我们需要一个usf文件来实现地形数据的生成:
WorldMapShader.usf
#include "/Engine/Public/Platform.ush"
// 输入输出参数
float seed;
RWTexture2D<float4> MapData;
// 定义线程组的大小
#define THREADS_X 16
#define THREADS_Y 16
// Grab from https://www.shadertoy.com/view/4djSRW
#define MOD3 float3(.1031,.11369,.13787)
//#define MOD3 float3(443.8975,397.2973, 491.1871)
float3 hash33(float3 p3)
{
p3 = frac(p3 * MOD3);
p3 += dot(p3, p3.yxz+19.19);
return -1.0 + 2.0 * frac(float3((p3.x + p3.y)*p3.z, (p3.x+p3.z)*p3.y, (p3.y+p3.z)*p3.x));
}
//-1~1
float simplex_noise(float3 p)
{
const float K1 = 0.333333333;
const float K2 = 0.166666667;
float3 i = floor(p + (p.x + p.y + p.z) * K1);
float3 d0 = p - (i - (i.x + i.y + i.z) * K2);
// thx nikita: https://www.shadertoy.com/view/XsX3zB
float3 e = step(float3(0., 0., 0.), d0 - d0.yzx);
float3 i1 = e * (1.0 - e.zxy);
float3 i2 = 1.0 - e.zxy * (1.0 - e);
float3 d1 = d0 - (i1 - 1.0 * K2);
float3 d2 = d0 - (i2 - 2.0 * K2);
float3 d3 = d0 - (1.0 - 3.0 * K2);
float4 h = max(0.6 - float4(dot(d0, d0), dot(d1, d1), dot(d2, d2), dot(d3, d3)), 0.0);
float4 n = h * h * h * h * float4(dot(d0, hash33(i)), dot(d1, hash33(i + i1)), dot(d2, hash33(i + i2)), dot(d3, hash33(i + 1.0)));
return dot(float4(31.316, 31.316, 31.316, 31.316), n);
}
//-1~1
float noise_sum(float3 p)
{
float f = 0.0;
p = p * 4.0;
float factor = 1.0;
for (int i = 0; i < 10; i++){
factor /= 2.;
f += factor * simplex_noise(p); p = 2.0 * p;
}
return f;
}
// 计算着色器
[numthreads(THREADS_X, THREADS_Y, 1)]
void MainCS(uint3 DispatchThreadID : SV_DispatchThreadID, uint3 GroupThreadID : SV_GroupThreadID, uint3 GroupID : SV_GroupID)
{
uint2 TextureSize;
MapData.GetDimensions(TextureSize.x, TextureSize.y);
const float2 uv = DispatchThreadID.xy / float2(TextureSize);
float noiseVal = noise_sum(float3(uv * .3, seed));
MapData[DispatchThreadID.xy] = float4(noiseVal * 1000., 0., 0., 0.);
}代码内容就不展开说了,主要就是用noise在一张RWTexture中填入了随机数据,结构和Unity的CS基本是一模一样的,唯一的区别是用hlsl写。
接下来我们需要在C++实现一个Class来对应这个usf文件:
WorldMapShader.h
// Copyright Epic Games, Inc. All Rights Reserved.
#pragma once
#include "CoreMinimal.h"
#include "RHI.h"
#include "RenderGraphResources.h"
#include "RHIDefinitions.h"
#include "GlobalShader.h"
#include "ShaderParameterStruct.h"
class FWorldMapShader : public FGlobalShader
{
DECLARE_GLOBAL_SHADER(FWorldMapShader);
// 生成一个构造函数,该构造函数将使用此FShader实例注册FParameter绑定。
SHADER_USE_PARAMETER_STRUCT(FWorldMapShader, FGlobalShader);
BEGIN_SHADER_PARAMETER_STRUCT(FParameters, /** MODULE_API_TAG */)
SHADER_PARAMETER(float, seed)
SHADER_PARAMETER_RDG_TEXTURE_UAV(RWTexture2D, MapData)
END_SHADER_PARAMETER_STRUCT()
static bool ShouldCompilePermutation(const FGlobalShaderPermutationParameters& Parameters)
{
return IsFeatureLevelSupported(Parameters.Platform, ERHIFeatureLevel::SM5);
}
static void ModifyCompilationEnvironment(const FGlobalShaderPermutationParameters& Parameters, FShaderCompilerEnvironment& OutEnvironment)
{
FGlobalShader::ModifyCompilationEnvironment(Parameters, OutEnvironment);
OutEnvironment.CompilerFlags.Add(CFLAG_AllowTypedUAVLoads);
}
};WorldMapShader.cpp
#include "WorldMapShader.h"
IMPLEMENT_GLOBAL_SHADER(FWorldMapShader, "/ShaderLibrary/WorldMapShader.usf", "MainCS", SF_Compute);此处需要注意几点:
类本身需要用
DECLARE_GLOBAL_SHADER宏标注为Shader类使用
SHADER_USE_PARAMETER_STRUCT宏来生成构造函数,当然也可以自己写与usf文件中参数对应的方式,是使用
BEGIN_SHADER_PARAMETER_STRUCT宏之间的部分来对应的使用
IMPLEMENT_GLOBAL_SHADER宏来将usf文件引入并绑定到本Class
至于Compile相关的两个接口主要是帮助调试用的,不是那么必要
接下来就尝试在逻辑中调用Shader吧:
ERHIFeatureLevel::Type FeatureLevel = curWorld->Scene->GetFeatureLevel();
FTextureRenderTargetResource* TextureRenderTargetResource = Texture->GameThread_GetRenderTargetResource();
if (FeatureLevel < ERHIFeatureLevel::SM5)
{
return;
}
// 这个宏用来向渲染线程插入一个由此lambda构成的GraphTask
// 渲染线程会从一个队列中不断获取GraphTask并执行
ENQUEUE_RENDER_COMMAND(TerrainGenerateCommand)(
[TextureRenderTargetResource, FeatureLevel, seed, &refreshSignal](FRHICommandListImmediate& RHICmdList)
{
// 使用RHICmdList来实例化一个GraphBuilder
FRDGBuilder GraphBuilder(RHICmdList);
// 在当前的GraphBuilder中为当前Pass分配参数
FWorldMapShader::FParameters* PassParameters = GraphBuilder.AllocParameters<FWorldMapShader::FParameters>();
// 设置参数
PassParameters->seed = seed;
FTextureRHIRef TextureRHI = TextureRenderTargetResource->GetTextureRHI();
FRDGTextureRef RDGTexture = GraphBuilder.RegisterExternalTexture(CreateRenderTarget(TextureRHI, TEXT("WorldMapRenderTarget")));
FRDGTextureUAV* MapData = GraphBuilder.CreateUAV(RDGTexture);
PassParameters->MapData = MapData;
// 获取全局着色器映射
// 该映射包含了所有已编译的着色器
const FGlobalShaderMap* GlobalShaderMap = GetGlobalShaderMap(FeatureLevel);
// 获取计算着色器的引用
TShaderMapRef<FWorldMapShader> ComputeShader(GlobalShaderMap);
// 让RDG帮我们释放不再使用的资源
ClearUnusedGraphResources(ComputeShader, PassParameters);
// 添加计算着色器通道
GraphBuilder.AddPass(
RDG_EVENT_NAME("WorldMap Shader"),
PassParameters,
ERDGPassFlags::Compute,
[ComputeShader, PassParameters, &refreshSignal](FRHICommandListImmediate& RHICmdList)
{
auto textureSize = PassParameters->MapData->Desc.Texture->Desc.GetSize();
const FIntVector GroupCount = FIntVector(
FMath::DivideAndRoundUp(textureSize.X, 16),
FMath::DivideAndRoundUp(textureSize.Y, 16),
1);
FComputeShaderUtils::Dispatch(RHICmdList, ComputeShader, *PassParameters, GroupCount);
refreshSignal = true;
});
GraphBuilder.Execute();
}可以发现我使用的接口和官方文档不是完全相同的,都是泪啊
但是做到这里,你直接调用,会发现当你调用FWorldMapShader相关行的时候会崩溃,观察执行栈会发现是IMPLEMENT_GLOBAL_SHADER宏相关行,也就是说在引入usf文件时,引擎没有找到对应的类,那么问题来了,是我们的shader没有成功编译,还是目录写错了呢?
实际上两者都是...
没错,Unity是这样的,UE要考虑的就多了...
Shader的编译条件
UE中usf文件被编译有两个先决条件,首先文件本身要放在设定好的shader目录中,其次需要使用类似IMPLEMENT_GLOBAL_SHADER的接口引入了项目,但这两点都有坑。
首先你需要知道现有的Shader目录有哪些,这个我们可以通过引擎的Log比较简单的看到:
Project/Saved/Logs/Project.log
...
LogShaders: Shader directory mapping /Engine -> ../../../Engine/Shaders
LogShaders: Shader directory mapping /Project -> ../../../../../WorkSpace/Project/Shaders
...可以直接用LogShaders来检索。
"->"后面的目录就是我们的usf文件应该放进去的目录,其他目录都是不行的,然后前面的目录(如"/Project")就是我们该使用在IMPLEMENT_GLOBAL_SHADER 中的虚拟目录。
如果想要使用自定义的目录,就需要使用AddShaderSourceDirectoryMapping接口来加入自己的目录:
AddShaderSourceDirectoryMapping(TEXT("/ShaderLibrary"), ShaderDir);需要注意的是,此接口需要在PostConfigInit阶段调用,也就是ini文件调用后就需要调用,否则依然会导致目录没有有效加入,从而导致目录下的shader不编译,所以推荐自己弄一个Module或者Plugin来专门引入自己的usf
目录加好后,你的IMPLEMENT_GLOBAL_SHADER 依然可能是无效的,这是因为IMPLEMENT_GLOBAL_SHADER 的引入时机,也需要是PostConfigInit的。如果你的shader目录成功加入了,但是引入是在主module中,崩溃的问题就还是会存在,因为shader还是没有成功编译,而且最坑的是这个过程是完全没有报错的,你可以通过故意把usf文件写错一行来测试是否成功编译到了。
如果成功报错了,就说明usf文件总算编译到了,到此上面的代码应该就可以调用成功了。
数据取回
需要注意的是,主线程在调用渲染线程后,当前帧渲染线程是不会执行相关逻辑的,它是以帧为单元的异步操作,你可以理解为是通过邮件来指派工作,主线程通过邮件通知渲染线程执行工作后,自己就去做别的事情了,等下一帧直接来取渲染线程的工作结果即可,所以此处当我们把RWTexture注册为UAV传给渲染线程,并调用了usf文件的逻辑之后,当前帧是取不到新数据的,需要下一帧再来。
读取RT的数据:
TArray<FLinearColor> m_dataCache;
FTextureRenderTargetResource* RTResource = m_data->GameThread_GetRenderTargetResource();
RTResource->ReadLinearColorPixels(m_dataCache);需要注意的是,这个读取数据的过程还是有效率代价的,所以推荐在非必要时不要执行,只有cpu需要用到此数据时再执行,并且同一帧不要执行多次,执行一次缓存即可,并且用signal变量来标记是否dirty,防止数据在未改变时还重复拉取。
接下来可以用UProceduralMeshComponent简单的把此数据渲染成mesh,来显示在场景里:
这是尝试用连续的seed每一帧生成一张地形的用例。
无视飞来飞去的小白点,那是在测试其他功能~

这个组件也是有点小坑的,不过相对CS来说已经想到好用了,此处就不展开说了
结语
总的来说UE的CS因为设计的复杂度和文档的问题还是导致有点使用门槛的,不过因为引擎本来也是开源的所以还是该反思下自己是不是太菜了,代码都摆在那里了还这么依赖文档!