林荫
林荫
发布于 2025-09-28 / 49 阅读
0
0

在UE中使用ComputeShader

前言

什么是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因为设计的复杂度和文档的问题还是导致有点使用门槛的,不过因为引擎本来也是开源的所以还是该反思下自己是不是太菜了,代码都摆在那里了还这么依赖文档!


评论