[Unreal Engine] Custom Vignette Pass & Editor Plugin 제작

Imeamangryang·2026년 2월 11일
post-thumbnail

0. 들어가기에 앞서

지난번에는 단순하게 RHI와 RDG를 이용해서 언리얼엔진에서 어떤 방식으로 엔진 수정 없이 커스텀 패스를 추가할 수 있는 지 알아보았다.

이번에는 에디터에서 파라미터를 수정해서 Custom Pass에서 만든 셰이더의 값을 조정해보도록 하겠다.

이번에는 Screen 효과 중 하나인 Vignette 효과를 Custom Pass로 직접 만들어 보자.



1. Project Settings

Engine Version : Unreal Engine 5.7 Source Build Version
OS : Windows 11

언리얼 엔진의 소스 빌드 버전을 사용하는 방법은 아래 링크를 참고해주세요.
Building Unreal Engine from Source

Unreal Engine의 Engine 내부의 Private RHI와 RDG 클래스들을 사용해야 하므로 소스 엔진이 아닌 일반 런처 엔진에서는 동작하지 않는다.



2. Plugin 생성 및 세팅

이번에는 언리얼 에디터 내부에서 셰이더의 파라미터 값을 실시간으로 수정할 수 있도록 에디터 독립형 창을 만들어보자.

2-1. Plugin 생성

우선 2개의 플러그인 템플릿을 합쳐 하나의 플러그인으로 만들어보자.

  • Blank(공백) : Vignette
  • Editor Independent Window : VignetteEditor

플러그인을 생성하면 한쪽 플러그인의 Source폴더를 복사해서 다른 쪽에 넣는다.
아래와 같은 폴더 구조를 만든다.

Vignette/
├─ Resources/
├─ Shaders/
│  ├─ Vignette.usf/
├─ Source/
│  ├─ Vignette/
│  │  ├─ Private/
│  │  ├─ Public/
│  │  └─ Vignette.Build.cs
│  └─ VignetteEditor/
│     ├─ Private/
│     ├─ Public/
│     └─ VignetteEditor.Build.cs
└─ Vignette.uplugin

2-2. Plugin Setting

Vignette.uplugin Setting

이번 플러그인은 Runtime 모듈과 Editor 모듈을 분리한 구조로 설계했다.
언리얼 플러그인에서는 실행 환경에 따라 모듈을 분리하는 것이 일반적인 구조다.

// Vignette.uplugin
{
	...
	"Modules": [
		{
			"Name": "Vignette",
			"Type": "Runtime",
			"LoadingPhase": "PostConfigInit"
		},
		{
			"Name": "VignetteEditor",
			"Type": "Editor",
			"LoadingPhase": "PostEngineInit"
		}
	]
}

Build.cs Setting

각 플러그인마다 이용하는 모듈을 등록해주어야 한다.
따라서 각각의 Build.cs에서 비공개 헤더 경로와 필요한 모듈 의존성을 명시적으로 추가한다.

// Vignette.Build.cs

public class Vignette : 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");
		}
// VignetteEditor.Build.cs

public class VignetteEditor : ModuleRules
{
		...
		PrivateDependencyModuleNames.AddRange(
			new string[]
			{
				"Projects",
				"InputCore",
				"EditorFramework",
				"UnrealEd",
				"ToolMenus",
				"CoreUObject",
				"Engine",
				"Slate",
				"SlateCore",
				"Vignette"
				// ... add private dependencies that you statically link with here ...	
			}
			);
        ...        

Vignette .h/.cpp

Vignette플러그인(Runtime Plugin)에서 커스텀 셰이더(.usf)를 사용하려면 엔진이 해당 셰이더 경로를 인식할 수 있도록 사전 설정이 필요하다.

이를 위해 모듈 로딩 시점(StartupModule)에 Virtual File System(VFS) 경로 매핑을 등록한다.

// Vignette.cpp
#include "Interfaces/IPluginManager.h"

... 

void FVignetteModule::StartupModule()
{
	// 셰이더 경로 매핑
    FString baseDir = IPluginManager::Get().FindPlugin(TEXT("Vignette"))->GetBaseDir();
    FString pluginShaderBaseDir = FPaths::Combine(baseDir, TEXT("Shaders"));
    AddShaderSourceDirectoryMapping(TEXT("/VignetteShaders"), pluginShaderBaseDir);
}


3. SceneViewExtension

우선 FSceneViewExtensionBase 클래스를 상속받아 FVignetteViewExtension 클래스를 만든다.

// FVignetteViewExtension.h
#pragma once

#include "SceneViewExtension.h"
#include "RenderResource.h"

DECLARE_GPU_STAT_NAMED_EXTERN(VignettePass, TEXT("VignettePass"));

class VIGNETTE_API FVignetteViewExtension : public FSceneViewExtensionBase
{
public:
	FVignetteViewExtension(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
};
// FVignetteViewExtension.cpp
#include "FVignetteViewExtension.h"

FVignetteViewExtension::FVignetteViewExtension(const FAutoRegister& AutoRegister) : FSceneViewExtensionBase(AutoRegister)
{
}

void FVignetteViewExtension::PrePostProcessPass_RenderThread(FRDGBuilder& GraphBuilder, const FSceneView& View, const FPostProcessingInputs& Inputs)
{
}

실제 구현 내용은 다른 설정을 마친 뒤 Pass를 추가할 때 넣어주겠다.



4. Shader 작성하기 (.usf)

설정했던 Shader 경로에 맞게 실제 파일 경로를 만들고 쉐이더 파일을 만들자.

// Vignette.usf
#include "/Engine/Public/Platform.ush"
#include "/Engine/Private/Common.ush"
#include "/Engine/Private/ScreenPass.ush"
#include "/Engine/Private/PostProcessCommon.ush"


int Intensity;
float Radius;
Texture2D SceneTexture;
SamplerState Vignette_Sampler;

// PS main
void MainPS(in noperspective float4 UVAndScreenPos : TEXCOORD0, float4 SvPosition : SV_POSITION, out float4 OutColor : SV_Target0)
{
	float2 uv = UVAndScreenPos.xy;
	uv *= 1.0f - uv; // map to -1 to 1
	
	float vig = (uv.x + uv.y) * Intensity;
	vig = pow(vig, Radius);

	OutColor = Texture2DSample(SceneTexture, Vignette_Sampler, UVAndScreenPos.xy) * vig;
}


5. Custom Shader 작성

이제 GPU가 작성한 .usf파일을 읽고 작업을 할 수 있도록 CPU에서 이를 연결해주는 C++ 쉐이더 코드를 작성하자.

지난번과 달리 이번에는 작성한 쉐이더 파일에서 Pixel Shader 하나만 사용하기 때문에 Pixel Shader에 대한 부분만 작성해주면 된다.

  • Pixel Shader 클래스 정의
// FVignettePS.h
#pragma once

#include "GlobalShader.h"
#include "ShaderParameterStruct.h"

BEGIN_SHADER_PARAMETER_STRUCT(FVignettePSParams,)
	SHADER_PARAMETER(int, Intensity)
	SHADER_PARAMETER(float, Radius)
	SHADER_PARAMETER_RDG_TEXTURE(Texture2D, SceneTexture)
	SHADER_PARAMETER_SAMPLER(SamplerState, Vignette_Sampler)
	RENDER_TARGET_BINDING_SLOTS()
END_SHADER_PARAMETER_STRUCT()

class FVignettePS : public FGlobalShader
{
	DECLARE_GLOBAL_SHADER(FVignettePS);
	using FParameters = FVignettePSParams;
	SHADER_USE_PARAMETER_STRUCT(FVignettePS, FGlobalShader)
};
// FVignettePS.cpp
#include "FVignettePS.h"

IMPLEMENT_SHADER_TYPE(, FVignettePS, TEXT("/VignetteShaders/Vignette.usf"), TEXT("MainPS"), SF_Pixel);


6. Subsystem 구성

Editor 플러그인과 우리가 만든 SceneViewExtension 사이에서 파라미터 값을 전달할 수 있도록 Subsystem을 구성해야한다.

이를 위해 셰이더의 파라미터 값을 받을 변수를 생성해야한다.
그리고 서브시스템이 안전하게 종료될 수 있도록 처리해준다.

// VignetteSubsystem.h
#pragma once

#include "CoreMinimal.h"
#include "Subsystems/EngineSubsystem.h"
#include "VignetteSubsystem.generated.h"

class FVignetteViewExtension;

UCLASS()
class VIGNETTE_API UVignetteSubsystem : public UEngineSubsystem
{
	GENERATED_BODY()
public:
	virtual void Initialize(FSubsystemCollectionBase& Collection) override;
	virtual void Deinitialize() override;

	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Vignette")
	float VigRadius = 0.25f;
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Vignette")
	int VigIntensity = 25;

protected:
	TSharedPtr<FVignetteViewExtension, ESPMode::ThreadSafe> VignetteViewExtension;
};
// VignetteSubsystem.cpp
#include "VignetteSubsystem.h"
#include "FVignetteViewExtension.h"
#include "SceneViewExtension.h"

void UVignetteSubsystem::Initialize(FSubsystemCollectionBase& Collection)
{
	Super::Initialize(Collection);
	
	VignetteViewExtension = FSceneViewExtensions::NewExtension<FVignetteViewExtension>();
}

void UVignetteSubsystem::Deinitialize()
{
	Super::Deinitialize();
	
	// 다른 강한 참조에 의해 살아남아 있을 경우를 대비하여 해당 SceneViewExtension이 수집되지 않도록 방지.
	{
		VignetteViewExtension->IsActiveThisFrameFunctions.Empty();

		FSceneViewExtensionIsActiveFunctor IsActiveFunctor;

		IsActiveFunctor.IsActiveFunction = [](const ISceneViewExtension* SceneViewExtension, const FSceneViewExtensionContext& Context)
		{
			return TOptional<bool>(false);
		};

		VignetteViewExtension->IsActiveThisFrameFunctions.Add(IsActiveFunctor);
	}

	ENQUEUE_RENDER_COMMAND(ReleaseSVE)([this](FRHICommandListImmediate& RHICmdList)
		{
			{
				VignetteViewExtension->IsActiveThisFrameFunctions.Empty();

				FSceneViewExtensionIsActiveFunctor IsActiveFunctor;

				IsActiveFunctor.IsActiveFunction = [](const ISceneViewExtension* SceneViewExtension, const FSceneViewExtensionContext& Context)
					{
						return TOptional<bool>(false);
					};

				VignetteViewExtension->IsActiveThisFrameFunctions.Add(IsActiveFunctor);
			}

			VignetteViewExtension.Reset();
			VignetteViewExtension = nullptr;
		});
	
	// 모든 렌더링 명령이 완료된 후 액터를 정리합니다.
	FlushRenderingCommands();
}


7. Custom Pass 추가하기

이번에는 FVignetteViewExtension안에서 RDG(Render Dependency Graph)를 이용한 Custom Post Process Pass를 추가해보자.

이전처럼 직접 AddPass로 PSO, 셰이더, 렌더 타겟을 구성하는 대신 엔진에서 제공하는 인터페이스를 사용해 더 간단하게 설정해보았다.

// FVignetteViewExtension.cpp
#include "FVignetteViewExtension.h"
#include "FVignettePS.h"
#include "FXRenderingUtils.h"
#include "ScreenPass.h"
#include "VignetteSubsystem.h"
#include "PixelShaderUtils.h"
#include "PostProcess/PostProcessing.h"
#include "PostProcess/PostProcessMaterial.h"
#include "RenderGraphUtils.h"

...

// 콘솔 명령으로 제어하는 CVAR
static TAutoConsoleVariable<int32> CVarEnableVignettePass(
    TEXT("r.VignettePass.Enable"),
    1,
    TEXT("Enable or disable Vignette Pass.\n")
    TEXT("0: Disable\n")
    TEXT("1: Enable"),
    ECVF_RenderThreadSafe);
// Usage:
// r.VignettePass.Enable 0   // Disable the render pass
// r.VignettePass.Enable 1   // Enable the render pass

...

void FVignetteViewExtension::PrePostProcessPass_RenderThread(FRDGBuilder& GraphBuilder, const FSceneView& View, const FPostProcessingInputs& Inputs)
{
    checkSlow(View.bIsViewInfo);
    
    // scene texture 유효성 검사
    Inputs.Validate();
    
    // Scene Color 텍스처 가져오기
    const FIntRect PrimaryViewRect = UE::FXRenderingUtils::GetRawViewRectUnsafe(View);
    FScreenPassTexture SceneColor((*Inputs.SceneTextures)->SceneColorTexture, PrimaryViewRect);

	// Render Target 설정 
    FScreenPassRenderTarget Output;
    if (!Output.IsValid())
    {
        Output = FScreenPassRenderTarget::CreateFromInput(GraphBuilder, SceneColor, View.GetOverwriteLoadAction(), TEXT("VignetteRenderTarget"));
    }

    const FScreenPassTextureViewport InputViewport(SceneColor);
    const FScreenPassTextureViewport OutputViewport(SceneColor);

    {
	    // RDG 이벤트 및 GPU 통계 범위 설정
        RDG_EVENT_SCOPE(GraphBuilder, "VignettePass");
        RDG_GPU_STAT_SCOPE(GraphBuilder, VignettePass);
	    
	    // 패스 파라미터 설정 (현재 Vertex Shaer는 사용하지 않으므로 PS 파라미터만 설정)
        FVignettePSParams* PixelShaderParams = GraphBuilder.AllocParameters<FVignettePSParams>();
		// 에디터에서 설정한 값을 사용, 에디터는 UVignetteSubsystem의 값을 가져옴
        UVignetteSubsystem* Subsystem = GEngine->GetEngineSubsystem<UVignetteSubsystem>();
        PixelShaderParams->Intensity = Subsystem ? Subsystem->VigIntensity : 25;
        PixelShaderParams->Radius = Subsystem ? Subsystem->VigRadius : 0.25f;
        PixelShaderParams->SceneTexture = SceneColor.Texture;
        PixelShaderParams->Vignette_Sampler = TStaticSamplerState<SF_Point, AM_Clamp, AM_Clamp, AM_Clamp>::GetRHI();
		// RT : SceneColor 
        Output.LoadAction = ERenderTargetLoadAction::ELoad;
        PixelShaderParams->RenderTargets[0] = Output.GetRenderTargetBinding();

		// 셰이더 참조 가져오기
	    FGlobalShaderMap* GlobalShaderMap = GetGlobalShaderMap(GMaxRHIFeatureLevel);
        TShaderMapRef<FVignettePS> PixelShader(GlobalShaderMap);
        check(PixelShader.IsValid());

        ClearUnusedGraphResources(PixelShader, PixelShaderParams); // 사용하지 않는 리소스 정리

        // Vignette 패스 추가
        if (CVarEnableVignettePass.GetValueOnGameThread()) {
        	// 화면에 Vignette 패스 적용
        	// AddDrawScreenPass에서 PSO, 셰이더&셰이더 파라미터 바인딩, 렌더 타겟 바인딩 등은 내부에서 처리
            AddDrawScreenPass(GraphBuilder, RDG_EVENT_NAME("Vignette"), static_cast<const FViewInfo&>(View), OutputViewport, InputViewport, PixelShader, PixelShaderParams);
            // 결과를 Scene Color에 다시 복사
            AddDrawTexturePass(GraphBuilder, View, Output.Texture, SceneColor.Texture);
            // 출력 로드 액션 설정
            Output.LoadAction = ERenderTargetLoadAction::ELoad;
        }
    }
}


8. Editor Setting

이제 마지막 단계로, 에디터 내부에서 커스텀 셰이더의 파라미터를 런타임으로 조정할 수 있는 UI를 만들어 보자.
Slate UI를 이용해 간단한 패널을 만들고, 서브시스템에 있는 값을 직접 수정하도록 구현한다.

  • 에디터에 커스텀 탭 생성
  • Vignette 관련 파라미터를 실시간으로 수정할 수 있는 Input UI
  • 수정된 값이 서브시스템에 반영되어 런타임 셰이더에 적용
// VignetteEditor.h
#pragma once
#include "Modules/ModuleManager.h"

class UVignetteSubsystem;
class FToolBarBuilder;
class FMenuBuilder;

class FVignetteEditorModule : 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;
	UVignetteSubsystem* VignetteSubsystem; // 셰이더의 파라미터 값을 가져오기 위한 서브시스템 변수
};
// VignetteEditor.cpp
...
#include "VignetteSubsystem.h"

...

TSharedRef<SDockTab> FVignetteEditorModule::OnSpawnPluginTab(const FSpawnTabArgs& SpawnTabArgs)
{
	VignetteSubsystem = GEngine->GetEngineSubsystem<UVignetteSubsystem>();

	return SNew(SDockTab)
	.TabRole(ETabRole::NomadTab)
	[
		SNew(SBorder)
			.Padding(10)
			.BorderImage(FCoreStyle::Get().GetBrush("NoBrush"))
			[
				SNew(SVerticalBox)

					// Editable number entry box for VigRadius (float)
					+ SVerticalBox::Slot()
					.AutoHeight()
					.Padding(5)
					[
						SNew(STextBlock)
							.Text(NSLOCTEXT("VignetteEditor", "VigRadiusLabel", "Vignette Radius"))
					]
					+ SVerticalBox::Slot()
					.AutoHeight()
					[
						SNew(SEditableTextBox)
							.Text(FText::FromString(FString::Printf(TEXT("%.2f"), VignetteSubsystem->VigRadius)))
							.OnTextCommitted_Lambda([this](const FText& NewText, ETextCommit::Type CommitType)
								{
									VignetteSubsystem->VigRadius = FCString::Atof(*NewText.ToString());
								})
					]

					// Editable number entry box for VigIntensity (int)
					+ SVerticalBox::Slot()
					.AutoHeight()
					.Padding(5)
					[
					SNew(STextBlock)
						.Text(NSLOCTEXT("VignetteEditor", "VigIntensityLabel", "Vignette Intensity"))
					]
					+ SVerticalBox::Slot()
					.AutoHeight()
					[
					SNew(SEditableTextBox)
						.Text(FText::FromString(FString::Printf(TEXT("%d"), VignetteSubsystem->VigIntensity))) // Display current value as text
						.OnTextCommitted_Lambda([this](const FText& NewText, ETextCommit::Type CommitType)
							{
								// Convert the entered text to an integer and update VigIntensity
								VignetteSubsystem->VigIntensity = FCString::Atoi(*NewText.ToString());
							})
					]
				]
		];
}


9. 결과

이제 에디터를 실행해서 플러그인을 적용해보면 다음과 같이 커스텀 에디터 창이 뜨는 것을 볼 수 있다.

  • Vignette Effect 결과

추가적으로 #7에서 적었던

// 콘솔 명령으로 제어하는 CVAR
static TAutoConsoleVariable<int32> CVarEnableVignettePass(
    TEXT("r.VignettePass.Enable"),
    1,
    TEXT("Enable or disable Vignette Pass.\n")
    TEXT("0: Disable\n")
    TEXT("1: Enable"),
    ECVF_RenderThreadSafe);
// Usage:
// r.VignettePass.Enable 0   // Disable the render pass
// r.VignettePass.Enable 1   // Enable the render pass

...
void FVignetteViewExtension::PrePostProcessPass_RenderThread(FRDGBuilder& GraphBuilder, const FSceneView& View, const FPostProcessingInputs& Inputs)
{
		...

        // Vignette 패스 추가
        if (CVarEnableVignettePass.GetValueOnGameThread()) {
        	// 화면에 Vignette 패스 적용
        	// AddDrawScreenPass에서 PSO, 셰이더&셰이더 파라미터 바인딩, 렌더 타겟 바인딩 등은 내부에서 처리
            AddDrawScreenPass(GraphBuilder, RDG_EVENT_NAME("Vignette"), static_cast<const FViewInfo&>(View), OutputViewport, InputViewport, PixelShader, PixelShaderParams);
            // 결과를 Scene Color에 다시 복사
            AddDrawTexturePass(GraphBuilder, View, Output.Texture, SceneColor.Texture);
            // 출력 로드 액션 설정
            Output.LoadAction = ERenderTargetLoadAction::ELoad;
        }
        
        ...

해당 코드의 기능을 통해 에디터의 Console Command로 Custom Pass를 On/Off 할 수 있다.


참고 자료
Pikachuxxxx - UE5VignetteEditor
mcro.de - Rendering Dependency Graph
ShaderToy - Simple vignette effect

profile
언리얼 엔진 주니어(신입) 개발자 | 소설 쓰는 취준 개발자

0개의 댓글