
제작했던 Vignette Pass의 구조를 활용해서 이번에는 Screen 효과 중 눈이 내리는 Snow 효과를 만들어보고자 한다.

Engine Version : Unreal Engine 5.7 Source Build Version
OS : Windows 11언리얼 엔진의 소스 빌드 버전을 사용하는 방법은 아래 링크를 참고해주세요.
Building Unreal Engine from Source
Unreal Engine의 Engine 내부의 Private RHI와 RDG 클래스들을 사용해야 하므로 소스 엔진이 아닌 일반 런처 엔진에서는 동작하지 않는다.
해당 과정의 자세한 설명과 과정 부분은 [Unreal Engine] Custom Vignette Pass & Editor Plugin 제작를 참고
지난번과 마찬가지로 플러그인을 생성하고
2가지 모듈을 만들어주자.
JustSnow/
├─ Resources/
├─ Shaders/
│ ├─ JustSnow.usf/
├─ Source/
│ ├─ JustSnow/
│ │ ├─ Private/
│ │ ├─ Public/
│ │ └─ JustSnow.Build.cs
│ └─ JustSnowEditor/
│ ├─ Private/
│ ├─ Public/
│ └─ JustSnowEditor.Build.cs
└─ JustSnow.uplugin
언리얼 플러그인에서는 실행 환경에 따라 모듈을 분리하는 것이 일반적인 구조다.
{
...
"Modules": [
{
"Name": "JustSnow",
"Type": "Runtime",
"LoadingPhase": "PostConfigInit"
},
{
"Name": "JustSnowEditor",
"Type": "Editor",
"LoadingPhase": "PostEngineInit"
}
]
}
각 플러그인마다 이용하는 모듈을 등록해주어야 한다.
따라서 각각의 Build.cs에서 비공개 헤더 경로와 필요한 모듈 의존성을 명시적으로 추가한다.
public class JustSnow : ModuleRules
{
...
PublicIncludePaths.AddRange(
new string[] {
// ... add public include paths required here ...
EngineDirectory + "/Source/Runtime/Renderer/Private",
EngineDirectory + "/Source/Runtime/Renderer/Internal"
}
);
...
PublicDependencyModuleNames.AddRange(
new string[]
{
"Core",
"RHI",
"Renderer",
"RenderCore",
"Projects"
// ... add other public dependencies that you statically link with here ...
}
);
...
// 에디터 모드에서만 필요한 의존성 추가
if (Target.Type == TargetType.Editor)
{
PrivateDependencyModuleNames.Add("UnrealEd");
PrivateDependencyModuleNames.Add("EditorWidgets");
}
public class JustSnowEditor : ModuleRules
{
...
PrivateDependencyModuleNames.AddRange(
new string[]
{
"Projects",
"InputCore",
"EditorFramework",
"UnrealEd",
"ToolMenus",
"CoreUObject",
"Engine",
"Slate",
"SlateCore",
"JustSnow"
// ... add private dependencies that you statically link with here ...
}
);
...
플러그인(Runtime Plugin)에서 커스텀 셰이더(.usf)를 사용하려면 엔진이 해당 셰이더 경로를 인식할 수 있도록 사전 설정이 필요하다.
이를 위해 모듈 로딩 시점(StartupModule)에 Virtual File System(VFS) 경로 매핑을 등록한다.
#include "Interfaces/IPluginManager.h"
...
void FVignetteModule::StartupModule()
{
// 셰이더 경로 매핑
FString baseDir = IPluginManager::Get().FindPlugin(TEXT("JustSnow"))->GetBaseDir();
FString pluginShaderBaseDir = FPaths::Combine(baseDir, TEXT("Shaders"));
AddShaderSourceDirectoryMapping(TEXT("/JustSnowShaders"), pluginShaderBaseDir);
}
FSceneViewExtensionBase 클래스를 상속받는 FJustSnowViewExtension를 만든다.
#pragma once
#include "SceneViewExtension.h"
#include "RenderResource.h"
DECLARE_GPU_STAT_NAMED_EXTERN(JustSnowPass, TEXT("JustSnowPass"));
class JUSTSNOW_API FJustSnowViewExtension : public FSceneViewExtensionBase
{
public:
FJustSnowViewExtension(const FAutoRegister& AutoRegister);
//~ Begin FSceneViewExtensionBase Interface
// virtual void SetupViewFamily(FSceneViewFamily& InViewFamily) override {}
// virtual void SetupView(FSceneViewFamily& InViewFamily, FSceneView& InView) override {}
// virtual void BeginRenderViewFamily(FSceneViewFamily& InViewFamily) override {}
virtual void PrePostProcessPass_RenderThread(FRDGBuilder& GraphBuilder, const FSceneView& View, const FPostProcessingInputs& Inputs) override;
//virtual void SubscribeToPostProcessingPass(EPostProcessingPass PassId, FAfterPassCallbackDelegateArray& InOutPassCallbacks, bool bIsPassEnabled);
//~ End FSceneViewExtensionBase Interface
};
#include "FJustSnowViewExtension.h"
FJustSnowViewExtension::FJustSnowViewExtension(const FAutoRegister& AutoRegister) : FSceneViewExtensionBase(AutoRegister)
{
}
void FJustSnowViewExtension::PrePostProcessPass_RenderThread(FRDGBuilder& GraphBuilder, const FSceneView& View, const FPostProcessingInputs& Inputs)
{
}
셰이더 원본 소스코드 : Just Snow
설정했던 Shader 경로에 맞게 실제 파일 경로를 만들고 쉐이더 파일을 만들자.
이번에는 셰이더 파라미터로 일반적인 int, float변수 외에도 마우스 입력 등을 받아 처리할 예정이다.
#include "/Engine/Public/Platform.ush"
#include "/Engine/Private/Common.ush"
#include "/Engine/Private/ScreenPass.ush"
#include "/Engine/Private/PostProcessCommon.ush"
int SnowLayers;
float2 MousePos;
float SnowDepth;
float SnowWidth;
float SnowSpeed;
float GlobalTime;
Texture2D SceneTexture;
SamplerState SceneTextureSampler;
float3 SnowEffect(float2 UV, float2 AspectRatio, float Time)
{
const float3x3 p = float3x3(
13.323122, 23.5112, 21.71123,
21.1212, 28.7312, 11.9312,
21.8112, 14.7212, 61.3934);
float3 acc = float3(0.0, 0.0, 0.0);
float dof = 5.0 * sin(Time * 0.1);
float2 fixedUV = UV * AspectRatio;
[loop]
for (int i = 0; i < 256; i++)
{
if (i >= SnowLayers) break;
float fi = (float)i;
float layerDepth = 1.0 + fi * SnowDepth;
float2 q = fixedUV * layerDepth;
// 3. 바람 및 낙하 계산 (Y축 방향 수정 Unreal 반대)
q += float2(
q.y * (SnowWidth * frac(fi * 7.238917) - SnowWidth * 0.5),
-SnowSpeed * Time / (1.0 + fi * SnowDepth * 0.03)
);
float3 n = float3(floor(q), 31.189 + fi);
float3 m = floor(n) * 0.00001 + frac(n);
float3 mp = (31415.9 + m) / (frac(mul(m, p)) + 0.00001);
float3 r = frac(mp);
float2 s = abs(frac(q) - 0.5 + 0.9 * r.xy - 0.45);
s += 0.01 * abs(2.0 * frac(10.0 * q.yx) - 1.0);
float d = 0.6 * max(s.x - s.y, s.x + s.y) + max(s.x, s.y) - 0.01;
float edge = 0.005 + 0.05 * min(0.5 * abs(fi - 5.0 - dof), 1.0);
acc += smoothstep(edge, -edge, d) * (r.x / (1.0 + 0.02 * fi * SnowDepth));
}
return acc;
}
void MainPS(
in noperspective float4 UVAndScreenPos : TEXCOORD0,
float4 SvPosition : SV_POSITION,
out float4 OutColor : SV_Target0)
{
float2 ViewSize = View.ViewSizeAndInvSize.xy;
float2 AspectRatio = float2(1.0, ViewSize.y / ViewSize.x);
float2 UV = UVAndScreenPos.xy;
float3 SceneColor = SceneTexture.Sample(SceneTextureSampler, UV).rgb;
float2 SnowUV = UV + (MousePos * 0.5);
float3 Snow = SnowEffect(SnowUV, AspectRatio, GlobalTime);
OutColor = float4(SceneColor + Snow, 1.0);
}
이제 GPU가 작성한 .usf파일을 읽고 작업을 할 수 있도록 CPU에서 이를 연결해주는 C++ 쉐이더 코드를 작성하자.
#pragma once
#include "GlobalShader.h"
#include "ShaderParameterStruct.h"
BEGIN_SHADER_PARAMETER_STRUCT(FJustSnowPSParams,)
SHADER_PARAMETER(int, SnowLayers)
SHADER_PARAMETER(FVector2f, MousePos)
SHADER_PARAMETER(float, SnowDepth)
SHADER_PARAMETER(float, SnowWidth)
SHADER_PARAMETER(float, SnowSpeed)
SHADER_PARAMETER(float, GlobalTime)
SHADER_PARAMETER_STRUCT_REF(FViewUniformShaderParameters, PassView)
SHADER_PARAMETER_RDG_TEXTURE(Texture2D, SceneTexture)
SHADER_PARAMETER_SAMPLER(SamplerState, SceneTextureSampler)
RENDER_TARGET_BINDING_SLOTS()
END_SHADER_PARAMETER_STRUCT()
class FJustSnowPS : public FGlobalShader
{
DECLARE_GLOBAL_SHADER(FJustSnowPS);
using FParameters = FJustSnowPSParams;
SHADER_USE_PARAMETER_STRUCT(FJustSnowPS, FGlobalShader)
};
#include "FJustSnowPS.h"
IMPLEMENT_GLOBAL_SHADER(FJustSnowPS, "/JustSnowShaders/JustSnow.usf", "MainPS", SF_Pixel);
Editor 플러그인과 우리가 만든 SceneViewExtension 사이에서 파라미터 값을 전달할 수 있도록 Subsystem을 구성해야한다.
이를 위해 셰이더의 파라미터 값을 받을 변수를 생성해야한다.
그리고 기본적으로 UEngineSubsystem는 Tick함수가 호출되지 않는 클래스이다.
마우스 입력을 받기 위해서는 FTickableGameObject 인터페이스를 구현하여 매 프레임마다 Tick이 호출되도록 해야한다.
#pragma once
#include "CoreMinimal.h"
#include "Subsystems/EngineSubsystem.h"
#include "JustSnowSubsystem.generated.h"
class FJustSnowViewExtension;
UCLASS()
class JUSTSNOW_API UJustSnowSubsystem : public UEngineSubsystem, public FTickableGameObject
{
GENERATED_BODY()
public:
virtual void Initialize(FSubsystemCollectionBase& Collection) override;
virtual void Deinitialize() override;
// Subsystem은 기본적으로 Tick이 안됨.
// FTickableGameObject 인터페이스를 구현하여 매 프레임마다 Tick이 호출되도록 함.
virtual void Tick(float DeltaTime) override;
virtual TStatId GetStatId() const override { RETURN_QUICK_DECLARE_CYCLE_STAT(UJustSnowSubsystem, STATGROUP_Tickables) } ;;
virtual bool IsTickable() const override { return true; }
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "JustSnow")
int SnowLayers = 50;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "JustSnow")
float SnowDepth = 0.5f;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "JustSnow")
float SnowWidth = 0.3f;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "JustSnow")
float SnowSpeed = 0.6f;
FVector2D MousePos;
FVector2f CachedMouseOffset;
protected:
TSharedPtr<FJustSnowViewExtension, ESPMode::ThreadSafe> JustSnowViewExtension;
};
#include "JustSnowSubsystem.h"
#include "FJustSnowViewExtension.h"
#include "SceneViewExtension.h"
void UJustSnowSubsystem::Initialize(FSubsystemCollectionBase& Collection)
{
Super::Initialize(Collection);
JustSnowViewExtension = FSceneViewExtensions::NewExtension<FJustSnowViewExtension>();
}
void UJustSnowSubsystem::Deinitialize()
{
Super::Deinitialize();
{
JustSnowViewExtension->IsActiveThisFrameFunctions.Empty();
FSceneViewExtensionIsActiveFunctor IsActiveFunctor;
IsActiveFunctor.IsActiveFunction = [](const ISceneViewExtension* SceneViewExtension, const FSceneViewExtensionContext& Context)
{
return TOptional<bool>(false);
};
JustSnowViewExtension->IsActiveThisFrameFunctions.Add(IsActiveFunctor);
}
ENQUEUE_RENDER_COMMAND(ReleaseSVE)([this](FRHICommandListImmediate& RHICmdList)
{
{
JustSnowViewExtension->IsActiveThisFrameFunctions.Empty();
FSceneViewExtensionIsActiveFunctor IsActiveFunctor;
IsActiveFunctor.IsActiveFunction = [](const ISceneViewExtension* SceneViewExtension, const FSceneViewExtensionContext& Context)
{
return TOptional<bool>(false);
};
JustSnowViewExtension->IsActiveThisFrameFunctions.Add(IsActiveFunctor);
}
JustSnowViewExtension.Reset();
JustSnowViewExtension = nullptr;
});
FlushRenderingCommands();
}
void UJustSnowSubsystem::Tick(float DeltaTime)
{
// 마우스 위치 저장
if (GEngine && GEngine->GameViewport)
{
GEngine->GameViewport->GetMousePosition(MousePos);
FVector2D Size;
GEngine->GameViewport->GetViewportSize(Size);
CachedMouseOffset = FVector2f(
MousePos.X / Size.X,
MousePos.Y / Size.Y
);
}
}
이제 Custom Snow Pass를 추가하자.
#include "FJustSnowViewExtension.h"
#include "FJustSnowPS.h"
#include "FXRenderingUtils.h"
#include "ScreenPass.h"
#include "JustSnowSubsystem.h"
#include "PixelShaderUtils.h"
#include "PostProcess/PostProcessing.h"
#include "PostProcess/PostProcessMaterial.h"
#include "RenderGraphUtils.h"
// 콘솔 명령으로 제어하는 CVAR
static TAutoConsoleVariable<int32> CVarEnableJustSnowPass(
TEXT("r.JustSnowPass.Enable"),
1,
TEXT("Enable or disable JustSnow Pass.\n")
TEXT("0: Disable\n")
TEXT("1: Enable"),
ECVF_RenderThreadSafe);
// Usage:
// r.JustSnowPass.Enable 0 // Disable the render pass
// r.JustSnowPass.Enable 1 // Enable the render pass
...
void FJustSnowViewExtension::PrePostProcessPass_RenderThread(FRDGBuilder& GraphBuilder, const FSceneView& View,
const FPostProcessingInputs& Inputs)
{
checkSlow(View.bIsViewInfo);
Inputs.Validate();
const FIntRect PrimaryViewRect = UE::FXRenderingUtils::GetRawViewRectUnsafe(View);
FScreenPassTexture SceneColor((*Inputs.SceneTextures)->SceneColorTexture, PrimaryViewRect);
FScreenPassRenderTarget Output;
if (!Output.IsValid())
{
Output = FScreenPassRenderTarget::CreateFromInput(GraphBuilder, SceneColor, View.GetOverwriteLoadAction(), TEXT("VignetteRenderTarget"));
}
const FScreenPassTextureViewport InputViewport(SceneColor);
const FScreenPassTextureViewport OutputViewport(SceneColor);
{
RDG_EVENT_SCOPE(GraphBuilder, "JustSnowPass");
RDG_GPU_STAT_SCOPE(GraphBuilder, JustSnowPass);
FJustSnowPSParams* PixelShaderParams = GraphBuilder.AllocParameters<FJustSnowPSParams>();
UJustSnowSubsystem* Subsystem = GEngine->GetEngineSubsystem<UJustSnowSubsystem>();
PixelShaderParams->SnowLayers = Subsystem ? Subsystem->SnowLayers : 50;
PixelShaderParams->MousePos = Subsystem ? Subsystem->CachedMouseOffset : FVector2f(0,0);
PixelShaderParams->SnowDepth = Subsystem ? Subsystem->SnowDepth : 0.5f;
PixelShaderParams->SnowWidth = Subsystem ? Subsystem->SnowWidth : 0.3f;
PixelShaderParams->SnowSpeed = Subsystem ? Subsystem->SnowSpeed : 0.6f;
PixelShaderParams->GlobalTime = View.Family->Time.GetWorldTimeSeconds();
PixelShaderParams->PassView = View.ViewUniformBuffer;
PixelShaderParams->SceneTexture = SceneColor.Texture;
PixelShaderParams->SceneTextureSampler = TStaticSamplerState<SF_Point, AM_Clamp, AM_Clamp, AM_Clamp>::GetRHI();
Output.LoadAction = ERenderTargetLoadAction::ELoad;
PixelShaderParams->RenderTargets[0] = Output.GetRenderTargetBinding();
FGlobalShaderMap* GlobalShaderMap = GetGlobalShaderMap(GMaxRHIFeatureLevel);
TShaderMapRef<FJustSnowPS> PixelShader(GlobalShaderMap);
check(PixelShader.IsValid());
ClearUnusedGraphResources(PixelShader, PixelShaderParams);
// JustSnowPass 추가
if (CVarEnableJustSnowPass.GetValueOnRenderThread()) {
AddDrawScreenPass(GraphBuilder, RDG_EVENT_NAME("JustSnow"), static_cast<const FViewInfo&>(View), OutputViewport, InputViewport, PixelShader, PixelShaderParams);
AddDrawTexturePass(GraphBuilder, View, Output.Texture, SceneColor.Texture);
Output.LoadAction = ERenderTargetLoadAction::ELoad;
}
}
}
이제 마지막 단계로, 에디터 내부에서 커스텀 셰이더의 파라미터를 런타임으로 조정할 수 있는 UI를 만들어 보자.
Slate UI를 이용해 간단한 패널을 만들고, 서브시스템에 있는 값을 직접 수정하도록 구현한다.
#pragma once
#include "Modules/ModuleManager.h"
class UJustSnowSubsystem;
class FToolBarBuilder;
class FMenuBuilder;
class FJustSnowEditorModule : public IModuleInterface
{
public:
/** IModuleInterface implementation */
virtual void StartupModule() override;
virtual void ShutdownModule() override;
/** This function will be bound to Command (by default it will bring up plugin window) */
void PluginButtonClicked();
private:
void RegisterMenus();
TSharedRef<class SDockTab> OnSpawnPluginTab(const class FSpawnTabArgs& SpawnTabArgs);
private:
TSharedPtr<class FUICommandList> PluginCommands;
UJustSnowSubsystem* JustSnowSubsystem;
};
...
#include "JustSnowSubsystem.h"
...
TSharedRef<SDockTab> FJustSnowEditorModule::OnSpawnPluginTab(const FSpawnTabArgs& SpawnTabArgs)
{
JustSnowSubsystem = GEngine->GetEngineSubsystem<UJustSnowSubsystem>();
return SNew(SDockTab)
.TabRole(ETabRole::NomadTab)
[
SNew(SBorder)
.Padding(10)
.BorderImage(FCoreStyle::Get().GetBrush("NoBrush"))
[
SNew(SVerticalBox)
// Editable number entry box for VigIntensity (int)
+ SVerticalBox::Slot()
.AutoHeight()
.Padding(5)
[
SNew(STextBlock)
.Text(NSLOCTEXT("JustSnowEditor", "SnowLayersLabel", "JustSnow SnowLayers"))
]
+ SVerticalBox::Slot()
.AutoHeight()
[
SNew(SEditableTextBox)
.Text(FText::FromString(FString::Printf(TEXT("%d"), JustSnowSubsystem->SnowLayers))) // Display current value as text
.OnTextCommitted_Lambda([this](const FText& NewText, ETextCommit::Type CommitType)
{
// Convert the entered text to an integer and update VigIntensity
JustSnowSubsystem->SnowLayers = FCString::Atoi(*NewText.ToString());
})
]
// Editable number entry box for VigRadius (float)
+ SVerticalBox::Slot()
.AutoHeight()
.Padding(5)
[
SNew(STextBlock)
.Text(NSLOCTEXT("JustSnowEditor", "SnowDepthLabel", "JustSnow SnowDepth"))
]
+ SVerticalBox::Slot()
.AutoHeight()
[
SNew(SEditableTextBox)
.Text(FText::FromString(FString::Printf(TEXT("%.1f"), JustSnowSubsystem->SnowDepth)))
.OnTextCommitted_Lambda([this](const FText& NewText, ETextCommit::Type CommitType)
{
JustSnowSubsystem->SnowDepth = FCString::Atof(*NewText.ToString());
})
]
// Editable number entry box for VigRadius (float)
+ SVerticalBox::Slot()
.AutoHeight()
.Padding(5)
[
SNew(STextBlock)
.Text(NSLOCTEXT("JustSnowEditor", "SnowWidthLabel", "JustSnow SnowWidth"))
]
+ SVerticalBox::Slot()
.AutoHeight()
[
SNew(SEditableTextBox)
.Text(FText::FromString(FString::Printf(TEXT("%.1f"), JustSnowSubsystem->SnowWidth)))
.OnTextCommitted_Lambda([this](const FText& NewText, ETextCommit::Type CommitType)
{
JustSnowSubsystem->SnowWidth = FCString::Atof(*NewText.ToString());
})
]
// Editable number entry box for VigRadius (float)
+ SVerticalBox::Slot()
.AutoHeight()
.Padding(5)
[
SNew(STextBlock)
.Text(NSLOCTEXT("JustSnowEditor", "SnowSpeedLabel", "JustSnow SnowSpeed"))
]
+ SVerticalBox::Slot()
.AutoHeight()
[
SNew(SEditableTextBox)
.Text(FText::FromString(FString::Printf(TEXT("%.1f"), JustSnowSubsystem->SnowSpeed)))
.OnTextCommitted_Lambda([this](const FText& NewText, ETextCommit::Type CommitType)
{
JustSnowSubsystem->SnowSpeed = FCString::Atof(*NewText.ToString());
})
]
]
];
}
에디터에서 플레이하면 마우스 위치에 따라 피사계가 달라지는 효과를 확인할 수 있다.

