Render Dependency Graph es un nuevo subsistema de rendering (versiones 4.22 y superiores) basado en la idea de descomponer las tareas asociadas al render en función de sus dependencias.
Esta idea es tomada "prestada" del motor Frostbite, salvo que en Frostbite lo llaman Framegraph.
Gracias a este grafo de dependencias explícitas se pueden conseguir mejoras en el rendimiento, mejoras en la gestión de memoria y mejor mantenibilidad del sistema de render.
¿Cómo se consigue esta mejora de rendimiento? Definiendo la tarea de render en una función lambda. Esta función lambda será ejecutada en el momento oportuno (cuando las dependencias estén satisfechas) y liberará los recursos cuando salgamos de su scope. Mejor rendimiento y mejor mantenibilidad.
La clase principal para interactuar con Render Dependency Graph es FRDGBuilder
Nota al margen: En el código fuente de RenderGraph.h
aparece documentación de gran relevancia en los comentarios.
Mandelbrot
Veamos un ejemplo sencillo, vamos a crear un compute shader para renderizar el famoso fractal de mandelbrot.
Empecemos por la parte de la declaración del shader:
Declaración del shader para Mandelbrot
Antes de nada si necesitas repasar que es un compute shader aquí implementamos uno.
- He creado un plugin llamado
RDGPlugin
dónde almacenar los shaders
También podrías haber usado un módulo aparte tal y como hacemos aquí.
- Al cargar el módulo debemos mapear la carpeta dónde están los shaders para que sean visibles al motor:
void FRDGPluginModule::StartupModule()
{
const FString PluginShaderDir = FPaths::Combine(IPluginManager::Get().FindPlugin(TEXT("RDGPlugin"))->GetBaseDir(), TEXT("Shaders"));
AddShaderSourceDirectoryMapping(TEXT("/Plugin/RDGPlugin"), PluginShaderDir);
}
- El fichero HLSL del compute shader para calcular mandelbrot es el siguiente:
#include "/Engine/Public/Platform.ush"
RWTexture2D<float> OutTexture;
float2 Size;
[numthreads(8, 8, 1)]
void mainCS(uint3 GroupId : SV_GroupID, uint3 DispatchThreadId : SV_DispatchThreadID, uint3 GroupThreadId : SV_GroupThreadID, uint GroupIndex : SV_GroupIndex)
{
float2 C = 2.0 * (float2(DispatchThreadId.xy) - 0.5 * Size) / Size;
C.y *= Size.y / Size.x; // fix aspect ratio
C += float2(-0.5, 0.0); // offset to center
float2 Z = C;
const int max_iter = 64;
int iter;
for (iter = 0; iter < max_iter; ++iter)
{
float2 Zn = Z;
Zn.x = Z.x * Z.x - Z.y * Z.y;
Zn.y = 2.0 * Z.x * Z.y;
Z = Zn + C;
if (dot(Z,Z) > 2.0)
{
break;
}
}
float s = 2.0 * abs(float(iter) / float(max_iter) - 0.5);
OutTexture[DispatchThreadId.xy] = s;
}
- Declara ahora la parte de la CPU, esto es, la clase que abstrae el shader y que usaremos como "puente" para comunicarnos:
// MandelbrotCS.h
#pragma once
#include "CoreMinimal.h"
#include "GlobalShader.h"
#include "ShaderParameterStruct.h"
class RDGPLUGIN_API FMandelbrotCS : public FGlobalShader
{
DECLARE_GLOBAL_SHADER(FMandelbrotCS)
SHADER_USE_PARAMETER_STRUCT(FMandelbrotCS, FGlobalShader)
BEGIN_SHADER_PARAMETER_STRUCT(FParameters,)
SHADER_PARAMETER_UAV(RWTexture<float>, OutTexture)
SHADER_PARAMETER(FVector2D, Size)
END_SHADER_PARAMETER_STRUCT()
public:
static bool ShouldCompilePermutation(const FGlobalShaderPermutationParameters& Parameters)
{
return RHISupportsComputeShaders(Parameters.Platform);
}
static inline void ModifyCompilationEnvironment(const FGlobalShaderPermutationParameters& Parameters, FShaderCompilerEnvironment& OutEnvironment)
{
FGlobalShader::ModifyCompilationEnvironment(Parameters, OutEnvironment);
//OutEnvironment.SetDefine(TEXT("THREADGROUPSIZE_X"), 8);
//OutEnvironment.SetDefine(TEXT("THREADGROUPSIZE_Y"), 8);
}
static constexpr int32 ThreadGroupSizeX = 8;
static constexpr int32 ThreadGroupSizeY = 8;
};
Y el fichero cpp:
#include "MandelbrotCS.h"
IMPLEMENT_GLOBAL_SHADER(FMandelbrotCS, "/Plugin/RDGPlugin/Mandelbrot.usf", "mainCS", SF_Compute);
Si los parámetros que declaras en la estructura de parámetros en C++ no hace match perfecto con los parámetros del propio fichero usf entonces unreal se quejará muy fuerte (crash!).
Y ahora la parte interesante con Render Dependency Graph.
Usar RDG para lanzar compute shader
Crea un actor llamado MandelbrotActor
en el módulo del proyecto:
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "MandelbrotActor.generated.h"
class UTextureRenderTarget2D;
UCLASS()
class RDGEXAMPLE_API AMandelbrotActor : public AActor
{
GENERATED_BODY()
public:
AMandelbrotActor();
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="Texture")
UTextureRenderTarget2D* MandelbrotTexture;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Mandelbrot")
int TextureWidth;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Mandelbrot")
int TextureHeight;
protected:
virtual void BeginPlay() override;
virtual void BeginDestroy() override;
public:
virtual void Tick(float DeltaTime) override;
private:
FUnorderedAccessViewRHIRef MandelbrotTextureUAV;
};
Fíjate que el último parámetro es precisamente el descriptor UAV que necesitamos pasarle a nuestro computer shader (en concreto en su parámetro OutTexture
).
Y su implementación, empezamos con el BeginPlay:
void AMandelbrotActor::BeginPlay()
{
Super::BeginPlay();
MandelbrotTexture = NewObject<UTextureRenderTarget2D>();
// this line is important:
MandelbrotTexture->bCanCreateUAV = true;
// because it's needed to create Unordered Access View (UAV)
MandelbrotTexture->bAutoGenerateMips = false;
MandelbrotTexture->RenderTargetFormat = ETextureRenderTargetFormat::RTF_R8;
MandelbrotTexture->MipsSamplerFilter = TextureFilter::TF_Bilinear;
MandelbrotTexture->ClearColor = FColor::Black;
MandelbrotTexture->InitAutoFormat(TextureWidth, TextureHeight);
MandelbrotTexture->UpdateResourceImmediate(true);
ENQUEUE_RENDER_COMMAND(Mandelbrot_CreateUAV)(
[this](FRHICommandListImmediate& RHICmdList)
{
auto TextureRHI = MandelbrotTexture->Resource->TextureRHI;
MandelbrotTextureUAV = RHICreateUnorderedAccessView(TextureRHI);
}
);
}
La parte más interesante es la creación del UAV.
Posteriormente lo pasaremos como parámetro al shader.
Otro punto interesante es la destrucción del mismo:
void AMandelbrotActor::BeginDestroy()
{
ENQUEUE_RENDER_COMMAND(Mandelbrot_DestroyUAV)(
[this](FRHICommandListImmediate& RHICmdList)
{
MandelbrotTextureUAV.SafeRelease();
}
);
Super::BeginDestroy();
}
Y ahora lanzar el compute shader vía RDG:
void AMandelbrotActor::Tick(float DeltaTime)
{
Super::Tick(DeltaTime);
auto FeatureLevel = GetWorld()->FeatureLevel;
ENQUEUE_RENDER_COMMAND(Mandelbrot_Render)(
[this, FeatureLevel](FRHICommandListImmediate& RHICmdList)
{
FRDGBuilder GraphBuilder(RHICmdList);
// aquí la parte interesante dónde añadiremos dependencias
GraphBuilder.Execute();
}
);
}
¿Recuerdas que usamos RDG para añadir dependencias? En concreto las dependencias son en forma de "pases".
En nuestro caso, queremos un "pase" de compute shader. Para ello podemos usar la biblioteca de utilidades de Compute Shader Utils:
void AMandelbrotActor::Tick(float DeltaTime)
{
Super::Tick(DeltaTime);
auto FeatureLevel = GetWorld()->FeatureLevel;
ENQUEUE_RENDER_COMMAND(Mandelbrot_Render)(
[this, FeatureLevel](FRHICommandListImmediate& RHICmdList)
{
FRDGBuilder GraphBuilder(RHICmdList);
TShaderMapRef<FMandelbrotCS> Shader /*...*/
FMandelbrotCS::FParameters* ShaderParams = GraphBuilder.AllocParameters<FMandelbrotCS::FParameters>();
/* initialize shader params */
const FIntVector ComputeGroupXYZCount(....);
FComputeShaderUtils::AddPass(
GraphBuilder,
RDG_EVENT_NAME("Mandelbrot"),
Shader,
ShaderParams,
ComputeGroupXYZCount
);
GraphBuilder.Execute();
}
);
}
El código final, con todo relleno, quedaría:
void AMandelbrotActor::Tick(float DeltaTime)
{
Super::Tick(DeltaTime);
auto FeatureLevel = GetWorld()->FeatureLevel;
ENQUEUE_RENDER_COMMAND(Mandelbrot_Render)(
[this, FeatureLevel](FRHICommandListImmediate& RHICmdList)
{
FRDGBuilder GraphBuilder(RHICmdList);
FGlobalShaderMap* GlobalShaderMap = GetGlobalShaderMap(FeatureLevel);
TShaderMapRef<FMandelbrotCS> Shader(GlobalShaderMap);
FMandelbrotCS::FParameters* ShaderParams = GraphBuilder.AllocParameters<FMandelbrotCS::FParameters>();
ShaderParams->Size = FVector2D(TextureWidth, TextureHeight);
ShaderParams->OutTexture = MandelbrotTextureUAV;
const FIntVector ComputeGroupXYZCount(
FMath::CeilToInt(TextureWidth / static_cast<float>(FMandelbrotCS::ThreadGroupSizeX)),
FMath::CeilToInt(TextureHeight / static_cast<float>(FMandelbrotCS::ThreadGroupSizeY)),
1);
// para GroupCount tambien se puede hacer uso de FComputeShaderUtils::GetGroupCount(...)
FComputeShaderUtils::AddPass(
GraphBuilder,
RDG_EVENT_NAME("Mandelbrot"),
Shader,
ShaderParams,
ComputeGroupXYZCount
);
GraphBuilder.Execute();
}
);
}
Fíjate que no necesitamos transicionar los recursos:
Ni siquiera en el Begin Play para preparar el recurso para su uso en compute shader:
¡Render Dependency Graph lo hace por nosotros bajo demanda!
Recursos externos
En Render Dependency Graph puedes crear cualquier tipo de recurso:
La gracia del asunto es que estos recursos, al estar gestionados por RDG, son autogestionados (alocados, optimizados y liberados) por el propio RDG.
Sin embargo, ¿qué ocurre si quisiéramos usar un buffer inicializado con unos datos?
Por la propia naturaleza de RDG, no puedes hacerlo de forma directa ya que el buffer ¡ni siquiera está disponible cuando haces CreateBuffer!
Su "allocation" es diferida en un punto del futuro cuando todo el grafo de dependencias es completado, y por tanto optimizado y listo para ser ejecutado.
¿Cómo lo hacemos? ¡Añadiendo un nuevo pase!
Nota al margen: Si vienes de hacer uso de la api legacy anterior a RDG, es decir, haciendo llamadas como RHICreateStructuredBuffer
para crear el buffer y/o haciendo uso de TResourceArray
puedes hacerlo pero deberás registrarlos con GraphBuilder.TryRegisterExternalBuffer(...)
ó GraphBuilder.TryRegisterExternalTexture(...)
para poder ser usado en RDG.
La buena noticia es que, es tan común crear un nuevo buffer e inicializarlo con datos, que Epic ya se ha encargado de encapsular este código en una función en RenderGraphUtils.h
Por lo que todo el código anterior quedaría:
Multiplicar por una constante
Veamos un caso muy sencillo pero muy ilustrativo, un compute shader que toma dos buffers, uno de entrada poblado con datos iniciales y otro de salida. El resultado es multiplicar el primer buffer por una constante (también pasada como parámetro).
El shader quedaría así de simple:
Por la parte de C++ la declaración es también muy simple:
Y ahora la parte interesante, la gestión en RDG:
Primero necesitamos crear los bufferes:
Lanzar el compute shader:
Y por último, copiar de vuelta los datos del buffer, en este caso creamos una subpass propia:
Notas importantes:
- Hacemos uso de
FCopyParameters
que es una estructura propia que hemos declarado (más adelante su declaración) - Se pone en los flags que el pase es
Readback
para que RDG optimice y haga sus validaciones. Ojo que poner otro flag puede ocasionar error de validación (por ejemplo si pones el flagCopy
espera que hagas uso de un render target y se quejará). - Es importante pasarle los parámetros del punto 1 ya que con esto RDG conoce el ciclo de vida de cada recurso y la dependencia entre pases.
La declaración de FCopyParameters
es la siguiente (se puede colocar en el cpp):
Fíjate como se indica RDG_BUFFER_ACCESS
y que el acceso será CopySrc
.
Por último es interesante el uso de FRenderCommandFence
para sincronizar el hilo de Draw y Game.
Esto es importante que cuando escribimos de vuelta el contenido del buffer al array Result queremos "esperarnos" a que se termine de copiar el contenido antes de acceder al mismo.
Un ejemplo sencillo
Aquí un ejemplo sencillo haciendo uso de todo lo visto hasta ahora. Se trata de unas esferas que son dibujadas en una textura (RenderTexture) que se mueven hacía arriba, estas esferas son especificadas a través de un Buffer.
Se trata de un ejemplo muy simple pero completo.
Aquí el shader:
El shader:
En cuanto a la creación del render target y del UAV es el mismo procedimiento que con el ejemplo de Mandelbrot.
La parte dónde lanzar el compute shader, en cada tick: