[Unreal Engine] Rendering & Graphics Programming 번역본

이매·2025년 6월 20일
post-thumbnail

출처 ㅣ Rendering & Graphics Programming with Unreal Engine(by: Julien Popa-Liesz)

1. Fundamentals of Graphics Programming

Graphics Pipeline

이 문서의 내용을 이해하려면 그래픽 렌더링 파이프라인에 대한 기본적인 이해가 필요합니다.

아래는 Vulkan 그래픽스 파이프라인의 예입니다. 그래픽스 파이프라인에 대해 이미 알고 있다면 이 섹션을 건너뛰어도 됩니다.

여러 플랫폼에서 사용되는 그래픽 파이프라인에는 여러 유형이 있으며 그 예로 Vulkan, DirectX, OpenGL 등이 있습니다. 모든 파이프라인은 Input Assembler, Vertex Shader, Rasterization and Fragment Shader (혹은 pixel shader) 와 같은 파이프라인의 공통 단계를 공유합니다.

일부 API는 Rasterizer 이전 또는 이후에 다른 단계가 있어 Rasterizer 전 후에 파이프라인 특정 작업을 해당 플랫폼에 최적화할 수 있습니다.

  1. input assembler는 사용자가 지정한 버퍼에서 raw vertex data를 수집하며, 제가 아는 한 HLSL과 같은 셰이더 코드와 결합하면 나중에 살펴보게 될 입력 시맨틱에 바인딩할 수 있습니다.
  2. Vertex shader는 모든 버텍스에 대해 실행되며 일반적으로 변환을 적용하여 버텍스 위치를 모델 공간에서 화면 공간으로 전환합니다. 또한 버텍스별 데이터를 파이프라인으로 전달합니다. 일반적으로 버텍스에서 월드 스페이스, 로컬 스페이스 또는 스크린 스페이스로 매트릭스 변환을 수행하기 전에 수행합니다. 버텍스 데이터는 표준 클립 스페이스에서 해석됩니다.

  1. Geometry shader는 모든 프리미티브(삼각형, 선, 점)에서 실행되며, 이를 버리거나 더 많은 프리미티브를 출력할 수 있습니다. 테셀레이션 셰이더와 비슷하지만 훨씬 더 유연합니다. 그러나 오늘날의 애플리케이션에서는 많이 사용되지 않으며 일부 API가 다르다는 점을 다시 한 번 강조합니다.
  2. rasterization stage에서는 프리미티브를 조각으로 분리합니다. 이것이 프레임버퍼에 채워지는 픽셀 요소입니다. 화면 외부에 있는 조각은 모두 폐기되고 그림과 같이 버텍스 셰이더가 출력한 속성이 조각 전체에 보간됩니다. 일반적으로 다른 프리미티브 조각 뒤에 있는 조각도 뎁스 테스트로 인해 여기서 버려집니다. 뎁스 테스트는 컬링 프로세스에 대한 정보를 저장하는 뎁스 버퍼와 함께 사용됩니다. 제가 알기로는 래스터라이저가 파이프라인에서 프로그래밍할 수 없는 유일한 곳입니다.
  3. Fragment Shader(또는 언리얼에서는 픽셀 셰이더)입니다. 픽셀 셰이더는 래스터화 단계에서 살아남은 모든 조각에 대해 호출되며, 조각이 어떤 프레임버퍼에 어떤 색상 및 뎁스 값과 함께 쓰여질지 결정합니다. 버텍스 셰이더의 보간된 데이터를 사용하여 이 작업을 수행할 수 있으며, 여기에는 텍스처 좌표 및 조명용 노멀 등이 포함될 수 있습니다.

보간에 대한 개념이 헷갈린다면 버텍스 셰이더와 픽셀 셰이더 사이의 이 예시 시나리오를 상상해 보세요.

두 개의 정점 값 A와 B가 있다고 가정합니다. 두 정점 모두 고유한 2D 화면 위치와 3D 픽셀 색상을 포함합니다. 이제 정점 A가 화면에서 색 값이 빨간색인 점이고 정점 B가 색 값이 파란색인 다른 점이고 A와 B를 연결하는 선을 그렸다면 다음과 같은 그림이 나타납니다.

빨간색(VA) - 보라색 - 파란색(B)

버텍스 셰이더는 A와 B를 연결하는 선을 그린 다음 픽셀 셰이더로 전달하여 A와 B 사이의 색상을 보간합니다. 포인트 A는 순수하게 빨간색이고 포인트 B는 순수하게 파란색으로 중간 색인 보라색(빨간색과 파란색이 혼합된)을 얻습니다.

  1. 픽셀 셰이더가 최종 이미지를 프레임버퍼에 넘기기 전에 API 특정 작업을 볼 수 있습니다.

GPU Buffers

이해해야 할 핵심 사항은 버퍼는 GPU의 메모리에 저장된 리소스일 뿐이라는 점입니다. 이러한 리소스는 CPU에서 선언된 다음 다양한 렌더링 프로세스 중에 사용하기 위해 GPU로 복사됩니다. 데이터를 GPU로 전달하기 전에 CPU에서 데이터를 정의할 때 정렬이 중요하므로 그래픽 API와 인터페이스할 때 패딩과 정렬에 대한 내용을 확인할 준비를 하세요. 버퍼의 종류는 매우 다양하지만 가장 일반적인 버퍼 몇 가지는 다음과 같습니다.

  • Vertex Buffer

input assembler가 읽을 모든 버텍스를 정의하는 데이터를 보유하고 GPU 파이프라인에서 지정한 버텍스 셰이더 코드에 바인딩합니다.

  • Index Buffer

Index Buffer는 버텍스 버퍼의 버텍스에 대한 포인터 배열이며, 인덱스 버퍼는 일반적으로 한 번에 세 개의 버텍스를 읽습니다. 하지만 오프셋을 지정할 수 있으므로 버텍스 버퍼에서 버텍스 셰이더로 공급할 여러 조합을 만들 수 있습니다. 화면 공간 전용 쿼드, 삼각형 또는 기타 프리미티브 등 다양한 드로 콜을 정의하여 사용할 수 있습니다.

  • Command Buffer

Command Buffer는 프레임을 렌더링하는 데 필요한 명령을 기록하는 데 사용됩니다. 이러한 명령에는 뷰포트 설정, 셰이더, 텍스처 바인딩, 지오메트리 렌더링을 위한 드로 콜 실행과 같은 작업이 포함됩니다. 명령 버퍼가 기록되면 실행을 위해 GPU에 제출할 수 있습니다.

  • Depth Buffer

Depth Buffer(Z Buffer라고도 함)는 3D 그래픽에서 화면의 각 픽셀에 대한 깊이 정보를 저장하는 데 사용되는 메모리 버퍼입니다. 장면에서 오브젝트의 상대적 깊이를 결정하고 뷰어에 더 가까운 오브젝트가 멀리 있는 오브젝트 위에 그려지도록 하는 데 사용됩니다.

  • GBuffer

“Geometry Buffer"라고도 하는 GBuffer는 3D 씬의 지오메트리에 대한 다양한 정보를 저장하는 데 사용되는 여러 개의 오프스크린 렌더 타깃 세트입니다. 이 정보에는 깊이, 노멀, 알베도(색상), 스페큘러 및 기타 데이터 등이 포함될 수 있습니다. GBuffer는 일반적으로 장면의 지오메트리를 캡처하는 프로세스와 해당 지오메트리에 조명 및 셰이딩을 적용하는 프로세스를 분리하는 기술인 deferred shading에 사용됩니다.

  • Texture Buffer

Texture Buffer는 텍스처 데이터를 저장하는 데 사용되는 GPU 버퍼입니다. 텍스처는 장면에 시각적 디테일과 사실감을 더하기 위해 3D 모델의 표면에 적용되는 2D 또는 3D 이미지입니다. 텍스처 버퍼는 텍스처의 픽셀 데이터를 저장하는 데 사용되며, 여기에는 색상, 알파, 노멀 정보 등이 포함될 수 있습니다.


HLSL

언리얼 엔진에서 셰이더 코드는 파일 확장자 형식이 .usf 및 .ush인 HLSL을 사용하여 작성됩니다.

저는 HLSL을 어셈블리 코드와 비슷하다고 상상하고 싶습니다. 입력 레지스터와 출력 레지스터가 있습니다. 버텍스 셰이더에서 입력을 정의하면 셰이더는 이를 픽셀 셰이더에 입력으로 출력합니다.

Regular semantics은 파이프라인의 특정 단계에 대한 입력 및 출력 데이터의 의미를 정의하는 데 사용됩니다. 여기에는 위치, 색상, 노멀, 텍스처 좌표 등이 포함될 수 있습니다. 이를 “Regular Semantics”이라고 부르고 원하는 이름을 지정할 수 있지만, 앞서 설명한 대로 입력 어셈블러를 통해 파이프라인에서 이러한 리소스(버퍼)를 바인딩해야 합니다.

반면에 'System semantics'은 특정 플랫폼이나 API에 특정한 입력 및 출력 데이터의 의미를 정의하는 데 사용됩니다. 이러한 시맨틱은 DirectX 또는 OpenGL API와 같이 GPU와 API 간에 데이터가 전달되는 방식을 정의하는 데 사용됩니다.

예를 들어, 특정 입력 변수에 DirectX 전용 시스템 시맨틱인 버텍스 ID가 포함되어 있음을 나타내기 위해 “SV_VERTEXID” 시맨틱을 사용할 수 있습니다. 이 시맨틱은 버텍스 셰이더가 현재 사용 중인 버텍스 버퍼가 처리 중인 현재 버텍스에 따라 SV_VERTEXID 시맨틱이 증가하는 DirectX 전용 드로 콜에 연결됩니다.

아래는 나중에 트라이앵글을 그리기 위해 바인딩할 트라이앵글 HLSL 코드입니다.

// 커스텀 버텍스 셰이더의 진입점을 위한 TriangleVS Binable Name  
void TriangleVS( 
	in float2 InPosition : ATTRIBUTE0,    // First Input Bindable Regular Symantic
	in float4 InColor : ATTRIBUTE1,       // Second Input Bindable Regular Symantic 
	out float4 OutPosition : SV_POSITION, // System Symantic Ouput position to Pixel Shader 
	out float4 OutColor : COLOR0          // System Symantic Output Color to Pixel Shader 
	) 
{
	OutPosition = float4(InPosition, 0, 1); 
	OutColor = InColor; 
} 

// 커스텀 픽셀 셰이더의 TrianglePS 바인딩 가능 진입점
void TrianglePS( 
	in float4 InPosition : SV_POSITION, // System Symantic Input position to Pixel Shader 
	in float4 InColor : COLOR0,         // System Symantic Input Color to Pixel Shader  
	out float4 OutColor : SV_Target0)   // System Symantic Render Target, IE. The 2D texture resource Render to. 
{
	OutColor = InColor; 
}

2. Unreal Engine Rendering Pipeline

언리얼의 렌더링 파이프라인에 대해 알아보기 전에 Rendering pass와 Deferred rendering의 개념에 대해 알아볼 필요가 있습니다.

Rendering pass는 GPU에서 실행되는 하나에서 여러 개의 드로우 호출로 구성된 집합입니다. 일반적으로 많은 Draw calls은 적절한 실행 순서를 보장하기 위해 함께 그룹화됩니다. 이는 이전 패스의 출력이 다른 순차적 패스의 입력으로 사용될 수 있기 때문입니다.

Deferred rendering은 라이트와 머티리얼을 별도의 패스로 렌더링하는 언리얼의 기본 메서드입니다. 이 별도의 패스는 베이스 패스가 불투명도, 스페큘러, 디퓨전, 노멀 등의 주요 정보에 대한 정보를 축적할 때까지 기다립니다. 아래 예시는 디퍼드 렌더링이 어떻게 작동하는지 보여줍니다.

언리얼은 래스터화할 때 각 픽셀의 라이팅과 셰이딩을 계산하는 대신 디퍼드 렌더링을 사용하여 씬의 지오메트리에 대한 정보를 “화면 밖”의 GBuffer에 캡처합니다. 이 버퍼는 씬에 라이팅과 셰이딩을 적용하기 위해 두 번째 패스에 사용됩니다. 이 버퍼의 장점은 GPU를 보다 효율적으로 사용할 수 있다는 것입니다.

지오메트리를 조명과 셰이딩에서 분리하면 GPU가 많은 수의 조명과 효과를 동시에 처리할 수 있으므로 성능이 향상될 수 있습니다. 따라서 Dynamic lighting과 복잡한 조명 설정에 유연성을 제공합니다.

Pass Order in Unreal Engine

이 패스는 변경될 수 있지만 일반적으로 이 순서대로 진행됩니다. RenderDoc을 다운로드하고 엔진 검증을 직접 해보는 것을 추천합니다.

Base Pass

  • Opaque or Masked materials의 최종 Attribute를 G-Buffer에 렌더링

  • Static lighting을 읽고 G-Buffer에 저장

  • DBuffer 데칼 적용

  • 안개(Fog) 적용

  • Final velocity 계산 (from packed 3D velocity)

  • Forward renderer에서의 Dynamic lighting

    Deferred mode에서 Base Pass는 머티리얼의 프로퍼티를 나중에 라이팅 계산을 위해 GBuffer defers에 저장합니다.

Geometry Passes

Geometry Passes는 라이팅 전에 메시를 그리고 우선순위를 지정하는 곳입니다.

PrePass

투명도에 따라 메시를 최적화하는 데 사용되는 Depth Z-Buffer의 PreRendering입니다. 이는 다른 메시 뒤에 숨겨져 보이지 않는 메시를 최적화하며 “Culling”(렌더링하지 않음)이라고 하는 방법입니다.

  • HZB
    • Hierarchy Z-Buffer 생성

스크린 스페이스 기법과 함께 Occlusion culling 방법을 사용하여 Ambient occlusion 및 Reflection을 적용하는 HZB입니다.

  • Render Velocities
    • 각 버텍스의 속도를 저장합니다. (나중에 Motion blur 및 Temporal anti-aliasing에 사용).

Velocity는 움직이는 모든 버텍스의 속도를 측정하여 모션 블러 속도 버퍼에 저장하는 버퍼입니다. Velocity buffer는 현재 프레임과 다음 프레임의 차이를 비교하여 마스크를 생성합니다. Doom 2016에서는 이 Velocity buffer를 사용하여 씬에서 정적이지 않은 메시를 마스킹하는데, 이는 다음 프레임에서 움직이지 않는 메시의 렌더링을 최적화하는 영리한 방법입니다.

Lighting Pass

특히 동적이고 그림자가 있는 광원이 많은 프레임에서 계산적으로 가장 하드코어한 부분입니다. 다음은 순서대로 나열한 것입니다.

  • Direct Lighting

    • Forward shading에서 최적화된 lighting
  • Non-Shadowed Lights

    • 그림자를 표현하지 않는 deferred rendering의 라이트
  • Shadowed Lights

    • Dynamic shadow를 확실하게 표현하는 라이트
  • Shadow Depths

    • 그림자를 표현하는 라이트의 Depth map 생성

  • Shadow Projection

    • 그림자의 Final Rendering
  • Indirect Lighting

    • Screen space의 ambient occlusion
    • Decals (non-Buffer type)

  • Composition After Lighting

    • Subsurface scattering 처리
  • Translucency and lighting

    • 반투명 머티리얼 렌더링
    • Surface forward shading을 사용하는 머티리얼의 라이팅
  • Reflections

    • 리플렉션 캡처 액터의 결과를 full-screen reflection buffer로 읽고 blending하기
  • Screen Space Reflections

    • Real-Time dynamic reflections
    • Screen-space ray tracing technique 기법을 사용하여 Post process에서 수행됩니다.
  • Post Processing

    • Depth of Field (BokehDOFRecombine)
    • Temporal anti-aliasing (TemporalAA)
    • Reading velocity values (VelocityFlatten)
    • Motion blur (MotionBlur)
    • Auto exposure (PostProcessEyeAdaptation)
    • Tone mapping (Tonemapper)
    • Rendering resolution에서 Display’s resolution로 Upscaling (PostProcessUpscale)

Post Processing은 렌더 파이프라인의 마지막 패스로, 삼각형을 그리는 렌더링 프로세스의 일부입니다.


Render Hardware Interface (RHI)

원래 RHI는 일부 리소스 관리 및 명령 인터페이스를 포함하여 D3D11 API를 기반으로 설계되었습니다.

언리얼 엔진은 모바일, 콘솔, PC 등 다양한 플랫폼을 지원하는 유비쿼터스 툴이기 때문에 플랫폼에 따라 다양한 API(DirectX, Vulkan, OpenGL, Metal)를 사용할 수 있습니다. 이를 해결하기 위해 언리얼은 게임 엔진 렌더링 코드를 최대한 포괄적으로 유지할 수 있도록 이러한 모든 API에 대한 인터페이스를 추상화했습니다.

이는 아래와 같이 다양한 렌더 스레드를 사용하여 달성됩니다:

사진에는 게임 스레드, 렌더 스레드, RHI 스레드가 있습니다.

언리얼엔진에서 이해해야 할 중요한 점은 (다른 특정 경우를 제외한다면) 렌더링되는 오브젝트가 게임 스레드와 렌더 스레드 사이에 표현된다는 것입니다.

언리얼 문서를 살펴보면 이에 대한 설명과 함께 Primitive Component(게임 스레드), Primitive Proxy(렌더 스레드)라는 명명 규칙을 확인할 수 있습니다.

  • Game Thread
    • Primitive Components
    • Light Components
  • Render Thread
    • Primitive Proxy
    • Light Proxy
  • RHI Thread
    • 지정된 API에 따라 렌더링 스레드에서 GPU로 RHI “immediate” instruction을 변환합니다.
    • DX12, Vulkan 등은 병렬 처리를 지원하므로 RHI immediate instruction가 생성되어 병렬화되면 아래 그림과 같이 RHI Thread가 이를 병렬로 변환합니다.

Note. RHI Immediate는 실제로 즉각적임을 의미하며 일반적으로 지연 명령인 regular RHI command와 동일한 호출이 아닙니다.

Basics of RHI

  • FRenderResource

FRenderResource는 렌더링 스레드의 렌더링 리소스 표현입니다. 이 리소스는 게임 스레드와 렌더 스레드 사이의 중간 데이터로 렌더링 스레드에서 관리하고 전달합니다.

/* 
* 렌더링 스레드가 소유하는 렌더링 리소스입니다.
* 참고 - 이 클래스에 새로운 가상 메서드를 추가하려면 FViewport/FDummyViewport에 스텁을 추가해야 하며, 
* 그렇지 않으면 특정 모듈에 링크 오류가 발생할 수 있습니다.
*/ 
class RENDERCORE_API FRenderResource 
{ 
public:
	/////////////////////////////////////////////////////////////////////////////////// 
	// 렌더링 리소스를 비동기적으로 초기화/해제하는 동안에는 다음 메서드를 호출할 수 없습니다.
	
	/** 현재 초기화된 모든 렌더링 리소스를 해제합니다. */ 
	static void ReleaseRHIForAllResources(); 
	
	/** RHI가 초기화되기 전에 초기화된 모든 리소스를 초기화합니다. */ 
	static void InitPreRHIResources();
	 
	/**
	* 이 리소스에서 사용하는 동적 RHI 리소스 및/또는 RHI 렌더 타깃을 초기화합니다. 
	* 리소스가 초기화될 때 또는 모든 RHI 리소스를 리셋할 때 호출됩니다. 
	* D3D 디바이스 리셋 후 초기화해야 하는 리소스는 이 함수를 구현해야 합니다. 
	* 렌더링 스레드에서만 호출됩니다.
	*/ 
	virtual void InitDynamicRHI() {} 
	
	/**
	* 이 리소스가 사용하는 동적 RHI 리소스 및/또는 RHI 렌더 타깃 리소스를 해제합니다. 
	* 리소스가 해제될 때 또는 모든 RHI 리소스를 리셋할 때 호출됩니다. 
	* D3D 디바이스 리셋 전에 해제해야 하는 리소스는 이 함수를 구현해야 합니다. 
	* 렌더링 스레드에서만 호출됩니다. 
	*/ 
	virtual void ReleaseDynamicRHI() {} 
	
	/**
	* 이 리소스가 사용하는 RHI 리소스를 초기화합니다. 
	* 리소스와 RHI가 모두 초기화된 상태로 진입할 때 호출됩니다. 
	* 렌더링 스레드에서만 호출됩니다.  
	*/ 
	virtual void InitRHI() {} 
	
	/**
	* 이 리소스가 사용 중인 RHI 리소스를 해제합니다. 
	* 리소스와 RHI가 모두 초기화된 상태에서 벗어날 때 호출됩니다. 
	* 렌더링 스레드에서만 호출됩니다. 
	*/ 
	virtual void ReleaseRHI() {}
	
	/** 
	* 리소스를 초기화합니다. 
	* 렌더링 스레드에서만 호출됩니다. 
	*/ 
	virtual void InitResource();
	
	/** 
	* 리소스를 삭제할 준비를 합니다. 
	* 렌더링 스레드에서만 호출됩니다. 
	*/ 
	virtual void ReleaseResource(); 
	
	/** 
	* 리소스의 RHI 리소스가 초기화되었다면 해제했다가 다시 초기화합니다.
	* 그렇지 않으면 아무것도 하지 않습니다. 
	* 이 함수는 렌더링 스레드에서만 호출됩니다.  
	*/ 
	void UpdateRHI();
	
	(...) 	
};

FRenderResource 클래스를 상속하는 많은 서브클래스가 있어 렌더링 스레드가 게임 스레드의 데이터와 연산을 다양한 추상화 수준을 가진 RHI 스레드로 전송할 수 있습니다.

  • FRHIResource

FRHIResource는 참조 카운팅(reference counting), 지연 삭제(delayed deletion), 추적 및 런타임 데이터 마킹(tracking & runtime data marking)에 이용됩니다.

FRHIResource는 state blocks, shader bindings, regular shaders, pipeline states, buffers, textures, views 및 RHI가 사용하는 기타 유형으로 나눌 수 있습니다. 이 클래스로 플랫폼별 유형을 만들 수 있다는 점에 유의해야 합니다. 자세한 내용은 FRHIUniformBuffer 클래스의 소스를 확인하세요.

/** RHI 리소스의 기본 유형입니다. */ 
class RHI_API FRHIResource 
{ 
public: 
	UE_DEPRECATED(5.0, "FRHIResource(bool) is deprecated, please use FRHIResource(ERHIResourceType)") 
	FRHIResource(bool InbDoNotDeferDelete=false) 
		: ResourceType(RRT_None) 
		, bCommitted(true) 
#if RHI_ENABLE_RESOURCE_INFO 
		, bBeingTracked(false) 
#endif 
	{ 
	} 
		
	FRHIResource(ERHIResourceType InResourceType) 
		: ResourceType(InResourceType) 
		, bCommitted(true) 
#if RHI_ENABLE_RESOURCE_INFO 
		, bBeingTracked(false) 
#endif 
	{ 
#if RHI_ENABLE_RESOURCE_INFO 
		BeginTrackingResource(this); 
#endif 
	}
	
	virtual ~FRHIResource() 
	{ 
		check(IsEngineExitRequested() || CurrentlyDeleting == this); 
		check(AtomicFlags.GetNumRefs(std::memory_order_relaxed) == 0); // this should not have any outstanding refs 
		CurrentlyDeleting = nullptr; 
		
#if RHI_ENABLE_RESOURCE_INFO 
		EndTrackingResource(this); 
#endif 
	} 
	
	FORCEINLINE_DEBUGGABLE uint32 AddRef() const 
	{...}; 
		
private: 
	// 모든 곳에 강제로 인라인 처리하는 것을 방지하기 위해 별도의 함수를 사용합니다. 
	// 코드 크기와 성능 모두에 도움이 됩니다.
	inline void Destroy() const 
	{...};

public:
	FORCEINLINE_DEBUGGABLE uint32 Release() const 
	{...}; 
	
	FORCEINLINE_DEBUGGABLE uint32 GetRefCount() const 
	{...};
	static int32 FlushPendingDeletes(FRHICommandListImmediate& RHICmdList); 
	
	static bool Bypass(); 
	
	bool IsValid() const 
	{...}; 
	void Delete() 
	{...}; 
	inline ERHIResourceType GetType() const { return ResourceType; }
	
#if RHI_ENABLE_RESOURCE_INFO
	// 사용 가능한 경우 리소스 정보를 가져옵니다. 
	// 리소스 정보가 데이터로 채워진 경우 true를 반환해야 합니다.
	virtual bool GetResourceInfo(FRHIResourceInfo& OutResourceInfo) const 
	{...}; 
	static void BeginTrackingResource(FRHIResource* InResource); 
	static void EndTrackingResource(FRHIResource* InResource); 
	static void StartTrackingAllResources(); 
	static void StopTrackingAllResources(); 
#endif 

private: 
	class FAtomicFlags 
	{ 
		static constexpr uint32 MarkedForDeleteBit    = 1 << 30; 
		static constexpr uint32 DeletingBit           = 1 << 31; 
		static constexpr uint32 NumRefsMask           = ~(MarkedForDeleteBit | DeletingBit); 
		
		std::atomic_uint Packed = { 0 }; 
		
	public: 
		int32 AddRef(std::memory_order MemoryOrder) 
		{...}; 
		int32 Release(std::memory_order MemoryOrder) 
		{...}; 
		bool MarkForDelete(std::memory_order MemoryOrder) 
		{...}; 
		 
		bool UnmarkForDelete(std::memory_order MemoryOrder) 
		{...}; 
		   
		bool Deleteing() 
		{...}; 
			
	mutable FAtomicFlags AtomicFlags; 
	
	const ERHIResourceType ResourceType; 
	uint8 bCommitted : 1; 
	
#if RHI_ENABLE_RESOURCE_INFO 
	uint8 bBeingTracked : 1; 
#endif 

	static std::atomic<TClosableMpscQueue<FRHIResource*>*> PendingDeletes; 
	static FHazardPointerCollection PendingDeletesHPC; 
	static FRHIResource* CurrentlyDeleting; 
	
	// 일부 API는 내부 참조 카운팅을 수행하지 않으므로 리소스를 삭제하기 전에 몇 프레임을 더 기다려야 합니다. 
	// GPU가 완전히 완료했는지 확인하기 위해 몇 프레임을 더 기다려야 합니다. 
	// 이렇게 하면 값비싼 펜스 등을 피할 수 있습니다. 
	struct ResourcesToDelete 
	{...}; 
}; 
  • FRHICommandList

RHI Command list는 명령 개체 그룹을 관리하고 실행하는 데 사용되는 명령 대기열입니다. FRHICommandList의 부모 클래스는 FRHICommandListBase입니다.

FRHICommandListBase는 명령 대기열에 필요한 기본 데이터(Command list, Device context)와 인터페이스(Command refresh, wait, enqueue, memory allocation)를 정의합니다.

FRHIComputeCommandList는 컴퓨팅 셰이더 간의 인터페이스, GPU 리소스의 상태 전환 및 셰이더 파라미터 설정을 정의합니다.

FRHICommandList는 버텍스 셰이더, 픽셀 셰이더, 지오메트리 셰이더, 프리미티브 드로잉, 셰이더 파라미터 및 리소스 관리 등 일반적인 렌더링 파이프라인의 인터페이스를 정의합니다.

  • RHIContext & DynamicRHI

마지막으로 그래픽 API 관련 연산 집합을 정의하는 또 다른 인터페이스 클래스인 RHIContext와 DynamicRHI도 있습니다. 앞서 언급했듯이 일부 API는 명령을 병렬로 처리할 수 있으므로 이 별도의 객체는 이를 정의하는 데 사용됩니다.

요약하자면, RHI 클래스는 언리얼에서 다양한 그래픽 API와 통신하는 데 사용되는 가장 낮은 레벨의 추상화입니다. 나열된 것들은 여러분이 알아야 할 주요 클래스입니다. RHI에 대한 자세한 내용은 레퍼런스 섹션에서 자세히 살펴볼 수 있습니다. 또한 명령 목록이 이해가 되지 않는다면 API를 사용하여 기본 명령 목록/버퍼가 GPU에 어떻게 처리되는지 찾아보세요.


3. Render Dependency Graph (RDG)

2017년 Yuriy O’Donnell은 프로스트바이트에서 일하면서 렌더 그래프 시스템을 개척하고 GDC에서 첫 번째 프레임 그래프를 선보였습니다. 그 결과 이 시스템이 제공하는 일련의 장점들이 언리얼 엔진에 도입되었고, 2021년 현재 렌더 그래프 사용은 AAA 게임 엔진 개발의 표준이 되었습니다.

  • Properties

렌더 그래프를 사용하면 렌더링 작업을 단일 방식으로 추상화하여 렌더링 코드를 생성할 수 있습니다. 이를 통해 코드가 명확해지고 리소스 수명과 렌더 패스 종속성을 해석할 수 있는 디버깅이 가능해져 렌더링 개발 시간이 효과적으로 단축됩니다.

DX12 및 Vulkan과 같은 차세대 그래픽 API는 수행하려는 작업에 따라 리소스 상태 및 전환을 관리합니다.

렌더 그래프를 사용하면 이러한 작업을 수동 입력 없이 자동으로 처리할 수 있습니다. 위 다이어그램에서 볼 수 있듯이 그래픽 프로그래머는 셰이더 입력, 렌더 타깃 및 뎁스 스텐실에 필요한 리소스를 선언할 수 있습니다. 리소스 전환은 그래프에서 처리되며, 녹색 선은 '읽기', 빨간색 선은 '쓰기' 작업으로 시각화할 수 있습니다.

각 렌더 그래프 노드는 이러한 상호 작용에 대한 지식을 가지고 있으며 리소스 전환을 위한 '배리어'를 배치할 수 있습니다. 즉, 최적의 배리어는 최적의 명령 대기열 설정을 보장합니다.

예를 들어 리소스 A가 패스 1의 셰이더 리소스로 사용되지만 패스 2의 렌더 타깃으로 사용되는 경우에도 두 타깃 사이를 전환하려면 리소스가 필요합니다. 이렇게 하면 호출이 줄어들고 불필요한 메모리 할당을 절약할 수 있습니다.

예를 들어 위 이미지에서 리소스 A는 세 번째 패스까지만 사용됩니다. 반면에 리소스 C는 네 번째 패스부터 사용되기 시작하므로 리소스 A의 수명과 겹치지 않으므로 두 리소스에 동일한 메모리를 사용할 수 있습니다.

리소스 A와 D에도 동일한 개념이 적용되며, 일반적으로 메모리 할당을 겹치는 방법은 여러 가지가 있으므로 최적의 할당 전략을 감지할 수 있는 방법이 필요합니다.

  • RDG Resources

“Per-frame"로 사용되는 리소스가 있는데, 렌더 그래프에서 수명을 완전히 처리할 수 있으므로 기술적으로는 graph 또는 transient resource라고 합니다. “Per-Frame” 리소스의 몇 가지 예로는 Deferred 라이팅 패스의 Gbuffer와 Camera Depth가 있습니다.

렌더 그래프의 Transient resource는 단일 프레임 내에서 특정 시간 동안 지속되므로 메모리 재사용 가능성이 높습니다. 그래프 외부에서 사용되는 리소스는 window swap chain back buffer와 같은 다른 리소스에 종속될 수 있으므로 이 경우 그래프는 상태를 관리하는 데만 제한됩니다. 이를 “External resources”라고 합니다.

  • Transient Resource System

Transient resources의 수명은 이러한 리소스에 대해 “Resource aliasing”(DX12 용어에 따름)이라고 하는 것을 가질 수 있습니다.

앨리어싱된 리소스는 특히 렌더 그래프를 사용할 때 사용되는 리소스 할당 공간의 50% 이상을 절약할 수 있습니다. 씬에 리소스 관리 복잡성이 추가되지만 메모리를 절약하려는 경우 그만한 가치가 있습니다.

  • Build Cross-Queues Synchronization

마지막으로 그래프를 통해 병렬로 실행되는 여러 명령어 대기열을 사용할 수 있으며, Dependency tree를 사용하여 이러한 메커니즘을 동기화하여 공유 리소스에 대한 Race Condition을 방지할 수 있습니다.

종속 대기열의 모든 패스를 배치한 후 갖게 되는 렌더링 패스의 비순환 그래프입니다.

종속성 트리의 각 레벨을 Dependency level이라고 합니다.

여기에는 리소스 사용량 측면에서 서로 독립적인 패스가 포함됩니다. 동일한 종속성 수준에 있는 모든 패스는 잠재적으로 비동기적으로 실행될 수 있습니다. 동일한 종속성 수준에서 동일한 대기열에 속하는 여러 패스가 있을 수 있지만 이는 문제가 되지 않습니다.

결과적으로 모든 종속성 레벨의 끝에 GPU 펜스와 동기화 지점을 배치하면 단일 그래픽 명령 목록의 모든 대기열에 대해 필요한 리소스 전환을 실행할 수 있습니다. 펜스를 사용하고 다른 명령어 대기열을 동기화하려면 시간 비용이 들기 때문에 이 방법은 비용이 발생합니다.

또한 항상 최적의 최소한의 동기화 양을 수행하는 것은 아니지만 허용 가능한 성능을 제공하며 가능한 모든 에지 케이스를 처리할 수 있습니다.

렌더 그래프의 장점을 한 마디로 요약하면 다음과 같습니다.

  • 더 나은 리소스 관리
  • 더 쉬운 디버깅 도구
  • 병렬 명령 목록 동기화.

이에 대한 추가 정보는 참고자료에 링크되어 있습니다.


RDG Dynamics

RDG의 맥락에서 이미 살펴본 내용 외에 몇 가지 새로운 용어에 대한 설명이 필요합니다.

  • View

단일 “viewport”가 FScene 을 바라보고 있습니다. 예를 들어 분할 화면에서 플레이하거나 VR에서 왼쪽 눈과 오른쪽 눈을 렌더링할 때는 두 개의 View를 갖게 됩니다.

  • Vertex Factory

버텍스 셰이더의 입력에 연결된 버텍스 데이터를 캡슐화하는 클래스입니다. 렌더링하는 데이터와 메시의 컨텍스트에 따라 다양한 버텍스 팩토리 유형이 있습니다.

  • Pooled Resource

RDG가 생성하고 처리하는 그래픽 리소스입니다. RDG 패스가 실행되는 동안에만 가용성이 보장됩니다.

  • External Resource

RDG와 독립적으로 생성된 그래픽 리소스입니다.

언리얼 엔진 RDG의 Workflow는 3단계의 프로세스로 구분할 수 있습니다

  1. Setup phase : 어떤 렌더 패스가 존재하고 어떤 리소스에 액세스할지 선언합니다.
  2. Compile phase : 리소스 수명을 파악하고 그에 따라 리소스를 할당합니다.
  3. Running/Execute phase : 모든 그래프 노드가 실행됩니다.

Setup Stage

Setup Stage는 렌더 스레드 메인 함수에 의해서만 트리거되는 FRenderModule 내부에서 시작됩니다. 보이는 View와 그와 관련된 모든 오브젝트에 대한 패스를 빌드합니다.

FDeferredShadingSceneRenderer::Render(FRHICommandListImmediate& RHICmdList)

모든 RDG 패스의 일반적인 사용법은 다음과 같은 예시로 생성됩니다.

// 패스에 필요한 리소스 인스턴스화 
FShaderParameterStruct* PassParameters = GraphBuilder.AllocParameters&lt;FShaderParameterStruct>();  

// 패스 매개변수 입력 
PassParameters->MyParameter = GraphBuilder.CreateSomeResource(MyResourceDescription, TEXT("MyResourceName"));  

// 패스를 정의하고 RDG 빌더에 추가 
GraphBuilder.AddPass( RDG_EVENT_NAME("MyRDGPassName"), PassParameters, ERDGPassFlags:: 
Raster, [PassParameters, OtherDataToCapture](FRHICommandList&RHICmdList) 
{  
	// … pass logic here, render something! ... 
}
  • Pass Name

이는 궁극적으로 패스에 대한 설명을 포함하는 FRDGEventName 유형의 객체로 표현됩니다. 디버깅 및 프로파일링 도구에 사용됩니다.

  • Pass Parameters

Pass Parameter는 GraphBuilder.AllocParameters()로 생성되고BEGIN_SHADER_PARAMETER_STRUCT(FMyShaderParameters, ) 매크로로 정의해야 하는 셰이더 매개변수 구조체에서 파생되어야 합니다.

Pass Parameter는 적절한 전환을 감지하기 위해 최소한 셰이더 리소스와 렌더 타깃을 구분해야 합니다(자세한 내용은 셰이더 파라미터에서 확인할 수 있습니다). 이 구조체는 다음과 같은 형태 중 하나로 정의됩니다:

  • 하나의 셰이더(예: Compute Shader)만 사용하는 경우, PassParameter는 FMyShader::FParameters 타입의 셰이더 유니폼 버퍼에서 가져옵니다.
  • 일반적으로 정의된 셰이더 유니폼 버퍼는 보통 .cpp 파일에서 정의되며, 이러한 버퍼는 전체 패스에 사용되기 때문에 이름에 보통 "PassParameters”가 포함됩니다.
  • Pass Flags

ERDGPassFlags 타입의 플래그 집합이며, 해당 패스가 수행할 작업의 종류(예: 래스터화, 복사, 컴퓨트 등)를 지정하는 데 사용됩니다.

  • Lambda Function

이 람다 함수는 패스의 "본문"을 포함하며, 런타임에 실행될 로직이 담깁니다. 람다 함수는 렌더링 작업에 필요한 객체들을 캡처해서 사용할 수 있습니다.

중요한 점은, 이 함수는 생성된 즉시 실행되지 않고 지연되어 실행되며, 특정 조건에서 즉시 실행될 수도 있다는 점입니다.

Compile Phase

Compile Phase는 완전히 자동화되어 있으며 "비프로그래머블(non-programmable)" 단계입니다. 즉, 렌더 패스를 작성하는 프로그래머가 이 단계에 직접 영향을 줄 수 없습니다.

이 단계에서는 렌더 그래프를 분석하여 가능한 모든 흐름 최적화 작업을 수행합니다.

주요 작업은 다음과 같습니다.

  1. 참조되지 않은 리소스와 패스는 제외: 예를 들어, 장면의 두 번째 디버그 뷰를 렌더링하고자 하는 경우, 해당 뷰에 필요한 특정 패스만 렌더링할 수 있습니다.
  2. 사용 중인 리소스의 수명 계산 및 처리
  3. 리소스 할당
  4. 최적화된 리소스 전이 그래프 생성

Running Stage

"Running Stage(실행 단계)"란 RDG 패스의 람다 함수가 실제로 실행되는 시점을 의미합니다. 이 실행은 비동기적으로 이루어지며, 정확한 타이밍은 RDG가 결정합니다.

람다 본문이 실행될 때, 사용할 수 있는 입력 값들은 해당 람다에서 캡처된 변수들과 커맨드 리스트입니다.

  • 계산 작업(Compute / AsyncCompute)의 경우: RHIComputeCommandList&
  • 래스터 작업(Raster)의 경우: FRHICommandList&

람다 본문 내에서 수행되는 핵심 작업은 다음과 같습니다:

  1. 파이프라인 상태 객체 설정: 래스터라이저, 블렌드 상태, 깊이/스텐실 상태 등을 설정합니다.
  2. 셰이더 및 속성 설정: 사용할 셰이더를 선택하고, 현재 파이프라인에 바인딩하며, 셰이더에 필요한 리소스를 커맨드 리스트를 통해 셰이더 슬롯에 바인딩합니다.
  3. Copy / Draw / Dispatch 명령 전송: 커맨드 리스트에 렌더링 명령을 전송합니다.

Execute Phase

Execution Phase(실행 단계)는 매우 단순하며, 컴파일 단계에서 제거되지 않고 살아남은 모든 패스를 순회하며, 각 패스의 Draw 및 Dispatch 명령을 커맨드 리스트에 실행하는 과정입니다.

이전 단계들에서는 모든 리소스가 불투명하고 추상화된 참조로 처리되었지만, 실행 단계에서는 실제 GPU API 리소스에 접근하고, 이를 파이프라인에 설정합니다.

CPU 측에서의 커맨드 리스트 준비 작업은 병렬화가 상당히 가능합니다. 대부분의 경우, 각 패스의 커맨드 리스트 설정은 서로 독립적이기 때문입니다.

단, 하나의 커맨드 큐에 커맨드 리스트를 제출하는 작업은 스레드 세이프하지 않으며, 병렬화를 도입할 경우 실제 성능 향상이 의미 있는지에 대한 판단이 필요합니다.


Shader Types

셰이더의 기본 클래스는 FShader이지만, 주요 셰이더 타입은 두 가지가 있다.

  • FGlobalShader

이 클래스를 상속받은 셰이더들은 모두 글로벌 셰이더 그룹에 속한다. 글로벌 셰이더는 엔진 전체에서 단일 인스턴스로 생성되며, 글로벌 파라미터만 사용할 수 있다.

  • FMaterialShader

이 클래스를 상속받은 셰이더들은 머티리얼에 연결된 파라미터를 사용하는 셰이더들이다. 만약 이들이 버텍스 팩토리(vertex factory)에 연결된 파라미터도 사용하는 경우, 해당 클래스는 FMeshMaterialShader를 참조하게 된다.

해당 문서의 예시에서는 포스트 프로세싱 패스 내에서 삼각형을 렌더링했기 때문에 Global Shader를 사용했다. Material Shader는 버텍스 팩토리에 강하게 연결되어 있어서, 이에 대해 학습하거나 실험해볼 시간이 없었다. 관련된 참고 자료는 문서 마지막에 따로 정리해두었다.


Shader Parameters

Shader Parameter는 셰이더가 사용하는 리소스 슬롯을 식별하는 역할을 하는 객체들이다. 이 파라미터들은 그래픽 또는 컴퓨트 연산에서 리소스를 설정할 때 사용된다.

셰이더 파라미터를 설정하는 과정은, 해당 파라미터가 지정한 인덱스 위치에 리소스를 커맨드 리스트에 바인딩하는 것으로 이루어진다.

바인딩과 컨텍스트 개념이 혼란스럽게 느껴질 수 있는데, 이는 일반적으로 GPU나 그래픽스 API에 대한 저수준 이해가 부족하기 때문이다. 이를 보완하기 위해 보충 설명 섹션에 간단한 배경 지식을 추가해 두었다.

셰이더 파라미터의 종류는 ShaderParametersUtils.h와 ShaderParameters.h파일에서 확인할 수 있다.

  • FShaderParameter

셰이더 파라미터의 레지스터 바인딩을 담당한다. 예: float1/2/3/4 형태의 값, 배열, UAV 등으로 사용될 수 있다.

  • FShaderResourceParameter

셰이더 리소스 바인딩을 담당하며, 텍스처나 샘플러 상태와 같은 리소스를 연결할 때 사용된다.

  • FRWShaderParameter

UAV 또는 SRV 리소스를 바인딩할 수 있는 클래스이다.

  • TShaderUniformBufferParameter

특정 구조체를 기반으로 하는 셰이더 유니폼 버퍼 바인딩을 위한 템플릿 클래스이다. 이 파라미터는 해당 셰이더에 필요한 모든 리소스를 포함하는 구조체를 참조한다. (이 부분은 뒤에서 더 자세히 다룬다.)

언리얼 엔진의 많은 경우처럼, 셰이더 파라미터 클래스도 매크로를 이용해 구성 방식을 정의한다.

셰이더 파라미터에서 가장 중요한 부분은 Layout이며, 이는 컴파일 시점에 정의되는 내부 변수로서 해당 파라미터 구조체가 어떤 필드들로 구성되어 있는 지를 지정한다.

LAYOUT_FIELD(MyDataType, MyFiledName); 
LAYOUT_FIELD(FShaderResourceParameter, UAVParameter);

레이아웃의 사용 방식은 셰이더 파라미터의 종류에 따라 달라지며, 파라미터 인덱스와 같은 다양한 데이터를 포함할 수 있다.

레이아웃의 목적은 항상 셰이더 파라미터에 대한 정보를 담는 데 있으며, 예를 들어 D3D12 기준으로는 CBV, SRV, UAV 같은 리소스 정보를 포함한다. 이 정보는 셰이더가 커맨드 리스트에서 실행될 때, 적절한 리소스를 바인딩하는 데 사용된다.

Shader Uniform Buffer Parameter

Unreal Engine에서 Uniform Buffer Parameter의 개념은 우리가 일반적인 컴퓨터 그래픽스에서 익숙한 것과 매우 다릅니다. 여기서는 본질적으로 셰이더 파라미터들의 구조체(struct)로 정의됩니다.

앞서 RDG(렌더링 의존성 그래프) 챕터에서 언급한 것처럼, Uniform Buffer는 셰이더 클래스 선언 내에 혹은 전역 범위에서 Shader Parameter Struct 매크로를 사용하여 정의할 수 있습니다.

	BEGIN_SHADER_PARAMETER_STRUCT(FMyShaderParameters, ) 
		SHADER_PARAMETER_RDG_TEXTURE(Texture2D, InputTexture) 
		SHADER_PARAMETER_SAMPLER(SamplerState, InputSampler) 
		RENDER_TARGET_BINDING_SLOTS() 
	END_SHADER_PARAMETER_STRUCT()

이 매크로 계열들은 다음을 포함할 수 있습니다:

  • Shader Parameters

텍스처, 샘플러, 버퍼, 디스크립터 등. 전체 매크로 목록은 ShaderParameterMacros.h 파일을 참고하세요.

  • Nested Structs

셰이더 파라미터 구조체 정의를 다른 구조체 안에 캡슐화할 수 있습니다. 이는 SHADER_PARAMETER_STRUCT(StructType, MemberName) 및 SHADER_PARAMETER_STRUCT_ARRAY(..) 매크로를 사용하여 구현됩니다.

  • Binding Slots

RENDER_TARGET_BINDING_SLOTS() 매크로를 사용하면, 파라미터 구조체에 할당 가능한 렌더 타겟 배열을 추가할 수 있습니다.

사용법

  • 셰이더 외부에서 정의할 경우, 이름은 보통 “FMyShaderParameters” 대신 FPassParameters 형태가 됩니다. 이 셰이더 파라미터 구조체들은 이 글의 RDG(렌더링 의존성 그래프) 섹션에서 설명한 대로 RDG의 Pass Parameters로 사용됩니다.
  • 셰이더 클래스 내부에서 정의할 경우, 이름은 보통 “FMyShaderParameters” 대신 FParameters가 됩니다. 또한, 셰이더 클래스 상단에 SHADER_USE_PARAMETER_STRUCT(FMyShaderClass, FBaseClassFromWhatMyShaderDerivesFrom) 매크로를 사용해야 합니다.

Set Shader Parameters

SetShaderParameters(TRHICmdList& RHICmdList, const TShaderRef&lt;TShaderClass>& Shader, TShaderRHI* ShadeRHI, const typename 
TShaderClass::FParameters& Parameters)

대부분의 경우, RDG 패스 내에서 셰이더를 사용할 때는 ShaderParameterStruct.h에서 제공하는 함수를 호출하여 입력 리소스를 특정 셰이더에 바인딩할 수 있습니다.

이 함수는 먼저 ValidateShaderParameters(Shader, Parameters);를 호출하여 모든 입력 셰이더 리소스가 셰이더 파라미터와 일치하는지 검사합니다.

그 후, 이 리소스들을 관련 파라미터에 바인딩하기 시작합니다. 이 섹션 초반에 나열된 모든 파라미터 타입들(예: FShaderParameter, FShaderResourceParameter 등)은 각각 자신만의 바인딩 호출 방식을 갖고 있어 커맨드 리스트에 바인딩됩니다.

BufferIndex는 모든 FParameterStructReference 타입과 기본 FParameters 요소들에 사용되며, 이들은 모두 버퍼에 저장됩니다.

입력 파라미터와 함께 CmdList::SetUniformBuffer 내부에서 무슨 일이 일어나는지는 우리가 사용하는 렌더 플랫폼에 따라 완전히 달라지며, 경우에 따라 크게 다를 수 있습니다.


Shader Macro Usage & Setup

Local Parameters

몇 가지 파라미터를 시작점으로 하여, 우리가 직접 Uniform Buffer(여러 셰이더에서 사용하는 상수 버퍼)를 생성하고자 한다면, HLSL 선언과 우리가 사용하는 매크로 설정 간의 예제를 통해 살펴보겠습니다.

float2 ViewPortSize;
float4 Hello;
float World;
float3 FooBarArray[16];

Texture2D BlueNoiseTexture;
SamplerState BlueNoiseSampler;

// 참고: Sampler State는 텍스처를 "샘플링"할 때 사용하는 객체로,
// 마스킹, 블렌딩 또는 기타 렌더링 기법을 적용하고자 할 때 샘플을 읽는 데 사용됩니다.
Texture2D SceneColorTexture;
SamplerState SceneColorSampler;

RWTexture2D<float4> SceneColorOutput;

앞서 설명한 내용처럼, 이러한 파라미터들을 바인딩하려면 내부 매크로를 사용해 구조체를 정의해야 합니다.

BEGIN_SHADER_PARAMETER_STRUCT(FMyShaderParameters,)
    SHADER_PARAMETER(FVector2f, ViewPortSize)
    SHADER_PARAMETER(FVector4f, Hello)
    SHADER_PARAMETER(float, World)
    SHADER_PARAMETER(FVector3f, FooBarArray, [16])

    SHADER_PARAMETER_TEXTURE(Texture2D, BlueNoiseTexture)
    SHADER_PARAMETER_TEXTURE(SamplerState, BlueNoiseSampler)

    SHADER_PARAMETER_TEXTURE(Texture2D, SceneColorTexture)
    SHADER_PARAMETER_TEXTURE(SamplerState, SceneColorSampler)
    SHADER_PARAMETER_UAV(RWTexture2D, SceneColorOutput)
END_SHADER_PARAMETER_STRUCT()

SHADER_PARAMETER_STRUCT 매크로는 컴파일 시점에 Reflection을 위한 메타데이터를 자동으로 생성해 줍니다.

const FShaderParametersMetadata* ParametersMetadata = FShaderParameters::FTypeInfo::GetStructMetadata();

Alignment Requirements

정렬(Alignment)을 반드시 지켜야 합니다. Unreal은 16바이트 자동 정렬 원칙을 채택하고 있으며, 따라서 구조체를 선언할 때 멤버의 순서가 중요합니다.

주요 규칙은 각 멤버가 자신의 크기보다 큰 다음 2의 제곱 수에 정렬된다는 것입니다. 단, 4바이트보다 큰 경우에만 해당됩니다. 예를 들면:

포인터는 8바이트 정렬됩니다.

  • float, uint32, int32는 4바이트 정렬
  • FVector2f, FIntPoint는 8바이트 정렬
  • FVector, FVector4f는 16바이트 정렬

이 정렬을 따르지 않으면, 컴파일 타임에 assert 문이 발생합니다.

각 멤버에 대한 자동 정렬은 패딩(padding) 을 유발하게 되며, 이는 아래에서 확인할 수 있습니다.

BEGIN_SHADER_PARAMETER_STRUCT(FMyShaderParameters,)
    SHADER_PARAMETER(FVector2f, ViewPortSize)            // 2 x 4 bytes = 8 bytes
    // → 2 x 4 바이트로 8바이트. 패딩 없이 정렬됨.

    SHADER_PARAMETER(FVector4f, Hello)                   // 4 x 4 bytes = 16 bytes
    // → 4 x 4 바이트로 16바이트. 패딩 없이 정렬됨.

    SHADER_PARAMETER(float, World)                       // 1 x 4 bytes
    // → 4바이트
    // → 다음 멤버가 16바이트 정렬(FVector3f)이므로, 12바이트 패딩 발생

    SHADER_PARAMETER(FVector3f, FooBarArray, [16])       // 4 x 4 x 16 = 256 bytes
    // → 16개의 FVector3f (12바이트씩) → 각각 16바이트로 정렬되기 때문에 패딩 포함됨

    SHADER_PARAMETER_TEXTURE(Texture2D, BlueNoiseTexture)        // 8 bytes
    SHADER_PARAMETER_TEXTURE(SamplerState, BlueNoiseSampler)     // 8 bytes

    SHADER_PARAMETER_TEXTURE(Texture2D, SceneColorTexture)       // 8 bytes
    SHADER_PARAMETER_TEXTURE(SamplerState, SceneColorSampler)    // 8 bytes

    SHADER_PARAMETER_UAV(Texture2D, SceneColorTextureOutput)     // 8 bytes
END_SHADER_PARAMETER_STRUCT()
SHADER_PARAMETER(FVector3f, WorldPositionAndRadius)  // DON'T DO THIS
// ----------------------- // 
// Do this 
SHADER_PARAMETER(FVector, WorldPosition) // Good 
SHADER_PARAMETER(float,WorldRadius) // Good 
SHADER_PARAMETER_ARRAY(FVector4f,WorldPositionRadius,[16]) // Good

Binding the Shader

셰이더 파라미터를 설정하고 정렬이 올바르게 되었으면, 이제 다음과 같이 선언됩니다.

SHADER_USE_PARAMETER_STRUCT(FMyShaderCS,FGlobalShader)
// Shader 클래스 정의
class FMyShaderCS : public FGlobalShader
{
    DECLARE_GLOBAL_SHADER(FMyShaderCS);
    SHADER_USE_PARAMETER_STRUCT(FMyShaderCS, FGlobalShader);

    static bool ShouldCompilePermutation(const FGlobalShaderPermutationParameters& Parameters) {
        return true;
    }

    using FParameters = FMyShaderParameters;
};

// 또는 struct 정의를 클래스 내부에 인라인으로 정의할 수도 있음
class FMyShaderCS : public FGlobalShader
{
    DECLARE_GLOBAL_SHADER(FMyShaderCS);
    SHADER_USE_PARAMETER_STRUCT(FMyShaderCS, FGlobalShader);

    static bool ShouldCompilePermutation(const FGlobalShaderPermutationParameters& Parameters) {
        return true;
    }

    using FParameters = FMyShaderParameters;

    BEGIN_SHADER_PARAMETER_STRUCT(FMyShaderParameters,)
        SHADER_PARAMETER(FVector2f, ViewPortSize)                    // 2 x 4 bytes
        SHADER_PARAMETER(FVector4f, Hello)                           // 4 x 4 bytes
        SHADER_PARAMETER(float, World)                               // 1 x 4 bytes
        SHADER_PARAMETER(FVector3f, FooBarArray, [16])               // 4 x 4 x 16 bytes

        SHADER_PARAMETER_TEXTURE(Texture2D, BlueNoiseTexture)        // 8 bytes
        SHADER_PARAMETER_TEXTURE(SamplerState, BlueNoiseSampler)     // 8 bytes

        SHADER_PARAMETER_TEXTURE(Texture2D, SceneColorTexture)       // 8 bytes
        SHADER_PARAMETER_TEXTURE(SamplerState, SceneColorSampler)    // 8 bytes

        SHADER_PARAMETER_UAV(Texture2D, SceneColorTextureOutput)     // 8 bytes
    END_SHADER_PARAMETER_STRUCT()
};

// C++ 코드에서 파라미터 설정 후 람다에 전달
FMyShaderParameters* PassParameters = GraphBuilder.AllocParameters<FMyShaderParameters>();

PassParameters->ViewPortSize = View.ViewRect.Size();
PassParameters->World = 1.0f;
PassParameters->FooBarArray[4] = FVector(1.0f, 0.5f, 0.5f);

// 글로벌로 선언한 셰이더 클래스에서 셰이더 참조 가져오기
TShaderMapRef<FMyShaderCS> ComputeShader(View.ShaderMap);
RHICmdList.SetComputeShader(ShaderRHI);

// 셰이더 파라미터 설정
SetShaderParameters(RHICmdList, *ComputeShader, ComputeShader->GetComputeShader(), Parameters);

// 디스패치 실행
RHICmdList.DispatchComputeShader(GroupCount.X, GroupCount.Y, GroupCount.Z);

Global Uniform Buffer

과정은 동일하지만 몇 가지 작은 차이점이 있습니다.

BEGIN_GLOBAL_SHADER_PARAMETER_STRUCT(FSceneTextureUniformParameters, /*Blah_API*/)

		// Scene Color / Depth
    SHADER_PARAMETER_TEXTURE(Texture2D, SceneColorTexture)
    SHADER_PARAMETER_SAMPLER(SamplerState, SceneColorTextureSampler)
    SHADER_PARAMETER_TEXTURE(Texture2D, SceneDepthTexture)
    SHADER_PARAMETER_SAMPLER(SamplerState, SceneDepthTextureSampler)
    SHADER_PARAMETER_TEXTURE(Texture2D<float>, SceneDepthTextureNonMS)

		// GBuffer
    SHADER_PARAMETER_TEXTURE(Texture2D, GBufferATexture)
    SHADER_PARAMETER_TEXTURE(Texture2D, GBufferBTexture)
    SHADER_PARAMETER_TEXTURE(Texture2D, GBufferCTexture)
    SHADER_PARAMETER_TEXTURE(Texture2D, GBufferDTexture)
    SHADER_PARAMETER_TEXTURE(Texture2D, GBufferETexture)
    SHADER_PARAMETER_TEXTURE(Texture2D, GBufferVelocityTexture)
		// ...
END_GLOBAL_SHADER_PARAMETER_STRUCT()

구조체 설정 후에는 구현 매크로를 호출해야 하며, 문자열은 해당 HLSL 파일에 정의된 이름입니다.

IMPLEMENT_GLOBAL_SHADER_PARAMETER_STRUCT(FSceneTextureUniformParameters, "SceneTextureStruct");

이제 언리얼 시스템 내부에서는 Common.ush파일이 코드 생성 시 참조되며, 이 Common.ush는 많은 HLSL 파일에 포함됩니다.

또한 셰이더 코드에서 사용할 수 있는 유틸리티 함수들이 들어 있는 다른 include 파일들도 있습니다.

이제 우리가 설정한 유니폼 버퍼는 어디서든 접근할 수 있습니다.

// 셰이더 컴파일에 필요한 유니폼 버퍼 선언을 포함하는 생성된 파일입니다.
"#include "/Engine/Generated/GeneratedUniformBuffers.ush"

이제 파라미터 구조체 내에서 유니폼 버퍼를 참조합니다.

BEGIN_SHADER_PARAMETER_STRUCT(FParameters,)
    //...
    // Here we ref our unifor buffer
    SHADER_PARAMETER_STRUCT_REF(FViewUniformShaderParameters,ViewUniformBuffer)
END_SHADER_PARAMETER_STRUCT()

람다 함수에 전달하기 전에 C++에서 다시 파라미터를 설정합니다.

FMyShaderParameters* PassParameters = GraphBuilder.AllocParameters<FMyShaderParameters>();
PassParameters.ViewportSize = View.ViewRect.Size();
PassParameters.World = 1.0f;
PassParameters.FooBarArray[4] = FVector(1.0f,0.5f,0.5f);
PassParameters.ViewUniformBuffer = View.ViewUniformBuffer;

4. Drawing a Triangle

이 지식을 바탕으로 다음 단계는 실제로 적용해보는 것입니다. 그래픽스 프로그래밍의 "Hello World"는 정점 셰이더(Vertex Shader)와 픽셀 셰이더(Pixel Shader)를 사용하여 기본적인 삼각형을 그리는 것입니다. 언리얼 엔진에서 삼각형을 그리는 것은 원한다면 엔진에서 RDG(Render Dependency Graph)를 사용하여 모든 렌더링 파이프라인을 설정하는 방법을 이해하는 데 도움이 됩니다.

이 섹션에서는 언리얼 엔진의 렌더 디펜던시 그래프(RDG)를 사용하여 렌더 패스를 생성하고 이 삼각형을 그리는 방법을 단계별 튜토리얼로 다룰 것입니다.

Plugin/Module & Shader Folder Binding Setup

플러그인 또는 모듈을 사용하는 것은 사용 사례에 따라 다르지만, 중요한 점은 플러그인 또는 모듈의 "Startup Module" 또는 "Initialize" 함수가 언리얼 엔진이 완전히 초기화되기 전에 코드를 실행할 수 있도록 한다는 것입니다. 이것이 중요한 이유는 언리얼의 렌더러가 모듈이기 때문입니다. 모듈과 엔진 수명 주기(Engine Life Cycle)를 이해하지 못한다면, 모듈의 런타임 링크에 대한 더 나은 이해를 위해 조금 읽어보시는 것을 추천합니다. 렌더러는 런타임에 링크되므로 셰이더 코드는 에디터가 시작되기 직전에 컴파일됩니다.

Plugin Setup

언리얼 엔진에서 기본적인 C++ 프로젝트를 만드세요. 1인칭 슈팅 게임(First Person shooter)이면 충분합니다.

프로젝트를 생성했으면, 폴더 구조로 이동하여 셰이더 폴더를 추가하세요.

대략적인 구조는 다음과 같습니다.

——————————————————————
├── YourProjectName
 ………├──> YourProjectName.Build.cs
 ………├──> Source Folder
 ………└──> ...
 ├──> Plugins
 …………├──> Your Plugin Folder
 ……………………└──> Shaders (Create the Folder here)  // 여기에 Shaders 폴더를 생성하세요
 ………….…….…….└──> .usf Files go here           // .usf 파일은 여기에 넣으세요
 ……………………├──> Source (Create the Folder here)  // 여기에 Source 폴더를 생성하세요
 ……………………………└──>Private
 ……………………………└──>Public
 ……………………………└──>YourPluginName.Build.cs
 ……….└──> YourPluginName.uplugin
——————————————————————

"PluginName.Build.cs 파일로 이동하여 종속성(dependencies)이 올바르게 설정되어 있는지 확인하십시오.”

using UnrealBuildTool;

public class YourPluginName : ModuleRules
{
    public YourPluginName(ReadOnlyTargetRules Target) : base(Target)
    {
        PCHUsage = ModuleRules.PCHUsageMode.UseExplicitOrSharedPCHs;

        PublicIncludePaths.AddRange(
            new string[]
            {
                // 여기에 필요한 public include path를 추가하세요 ...
                EngineDirectory + "/Source/Runtime/Renderer/Private"
            }
            );

        PrivateIncludePaths.AddRange(
            new string[]
            {
                // 여기에 필요한 다른 private include path를 추가하세요 ...
            }
            );

        PublicDependencyModuleNames.AddRange(
            new string[]
            {
                "Core",
                "RHI",
                "Renderer",
                "RenderCore",
                "Projects",
                // 여기에 정적으로 링크할 다른 public dependencie을 추가하세요 ...
            }
            );

        PrivateDependencyModuleNames.AddRange(
            new string[]
            {
                "CoreUObject",
                "Engine",
                "Slate",
                "SlateCore",
                // 여기에 정적으로 링크할 private dependencie을 추가하세요 ...
            }
            );

        DynamicallyLoadedModuleNames.AddRange(
            new string[]
            {
                // 여기에 모듈이 동적으로 로드하는 모든 모듈을 추가하세요 ...
            }
            );
    }
}

다음으로 YourPluginName.uplugin 파일 내에서 모듈 로딩 단계를 "PostConfigInit"으로 설정합니다.

{
    "FileVersion": 3,
    "Version": 1,
    "VersionName": "1.0",
    "FriendlyName": "YourPluginName",
    "Description": "",
    "Category": "Other",
    "CreatedBy": "",
    "CreatedByURL": "",
    "DocsURL": "",
    "MarketplaceURL": "",
    "SupportURL": "",
    "CanContainContent": true,
    "IsBetaVersion": false,
    "IsExperimentalVersion": false,
    "Installed": false,
    "Modules": [
        {
            "Name": "YourPluginName",
            "Type": "Runtime",
            "LoadingPhase": "PostConfigInit" // Set it Here
        }
    ]
}

다음 단계는 언리얼과 셰이더 폴더를 바인딩하여 커스텀 셰이더 코드를 찾고 컴파일할 수 있도록 하는 것입니다.

#include "FroyokLensFlarePlugin.h"
#include "Interfaces/IPluginManager.h"

#define LOCTEXT_NAMESPACE "FFroyokLensFlarePluginModule"

void FYourPluginNameModule::StartupModule()
{
		// 이 코드는 모듈이 메모리에 로드된 후 실행됩니다
		// 정확한 타이밍은 .uplugin 파일의 모듈별 설정에 명시되어 있습니다.
    FString BaseDir = IPluginManager::Get().FindPlugin(TEXT("YourPluginName"))->GetBaseDir();
    FString PluginShaderDir = FPaths::Combine(BaseDir, TEXT("Shaders"));
    AddShaderSourceDirectoryMapping(TEXT("/CustomShaders"), PluginShaderDir);
}

void FYourPluginNameModule::ShutdownModule()
{
    // 이 함수는 모듈 종료 시 모듈을 정리하기 위해 호출될 수 있습니다. 
    // 동적 리로딩을 지원하는 모듈의 경우,
    // 모듈을 언로드하기 전에 이 함수를 호출합니다.
}

#undef LOCTEXT_NAMESPACE

IMPLEMENT_MODULE(FYourPluginNameModule, YourPluginNamePlugin)

플러그인 셰이더 폴더의 기본 디렉토리를 가져오는 헬퍼 함수에 접근할 수 있도록 "Interfaces/IPluginManager.h"를 포함해야 합니다.

"AddShaderSourceDirectoryMapping(TEXT("/CustomShaders"), PluginShaderDir)" 코드는 기본적으로 "/CustomShaders"라는 가상 폴더를 바인딩합니다. 이름은 중요하지 않은 것 같지만, 원하는 대로 이름을 지정할 수 있습니다. 제가 틀릴 수도 있습니다.

다음으로 YourPlugin 폴더로 이동하여 Private 폴더 안에 MyViewExtensionSubSystem.cpp 파일을, Public 폴더 안에 MyViewExtensionSubSystem.h 파일을 각각 추가합니다.
이 서브시스템은 PostConfigInit 단계에서 이 객체에 대한 커스텀 FSceneViewExtensionBase 포인터를 생성하는 데 이상적입니다. 이를 통해 에디터 뷰포트에 삼각형을 그릴 수 있습니다.

MyCharacter.hTSharedPtr 객체를 선언하고 BeginPlay에서 인스턴스화할 수도 있습니다. 그렇게 하면 에디터에서 플레이를 누른 후에 삼각형이 렌더링됩니다.

Module Setup

// MyViewExtensionSubSystem.cpp

#include "MyViewExtensionSubSystem.h"
#include "SceneViewExtension.h"
#include "MyViewExtension.h"

void UMyViewExtensionSubSystem::Initialize(FSubsystemCollectionBase& Collection)
{
    Super::Initialize(Collection);
    // Shared Pointer 호출 생성
    UE_LOG(LogTemp, Warning, TEXT("View Extension Subsystem Init"));
    // 이것은 나중에 보게 될 FSceneViewExtension에 대한 포인터입니다.
    // 셰이더를 실행하려면 이 줄이 필요합니다.
    this->ShaderTest = FSceneViewExtensions::NewExtension<FMyViewExtension>();
}
// MyViewExtensionSubSystem.h
#pragma once

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

class FViewExtension;
/**
 * */
UCLASS()
class MYPLUGINNAME_API UMyViewExtensionSubSystem : public UEngineSubsystem
{
    GENERATED_BODY()

protected:
    // 언리얼의 FViewExtension 객체에 대한 포인터 델리게이트 선언
    TSharedPtr<FViewExtension, ESPMode::ThreadSafe> ShaderTest;

public:
    virtual void Initialize(FSubsystemCollectionBase& Collection) override;
};

FSceneViewExtensionBase

이 기본 클래스는 렌더 파이프라인에 연결하고 그 델리게이트를 사용하여 자신만의 커스텀 렌더 패스를 삽입할 수 있도록 해주기 때문에 중요합니다.

5.1 버전 이전에는 자신만의 커스텀 렌더링 패스를 생성하려면 셰이더 폴더를 초기화하기 위해 여전히 플러그인이나 모듈이 필요했습니다. 하지만 이 클래스가 없다면, 커스텀 렌더링 패스를 위해 렌더 파이프라인 소스 코드 내부에 자신만의 델리게이트를 추가하는 추가적인 C++ 작업을 해야 할 것입니다.

이 튜토리얼에서는 렌더 파이프라인의 후처리(Post Processing) 단계에 패스를 삽입하기 위해 FSceneViewExtensionBase 클래스의 델리게이트 함수를 오버라이드할 것입니다.

엔진 소스 코드 PostProcessing.cpp 파일 411번째 줄에서 FSceneViewExtensionBase의 델리게이트가 추가됩니다.

// ../../Engine./PostProcessing.cpp

const auto AddAfterPass = [&](EPass InPass, FScreenPassTexture InSceneColor) -> FScreenPassTexture
{
    // 어떤 경우에는 (예: OCTO 색상 변환) 뷰 확장 기능이 패스 후에 추가적인 커스텀 후처리를 할 수 있도록 원합니다.

    FAfterPassCallbackDelegateArray& PassCallbacks = PassSequence.GetAfterPassCallbacks(InPass);

    if (PassCallbacks.Num())
    {
        FPostProcessMaterialInputs InOutPostProcessAfterPassInputs = GetPostProcessMaterialInputs(InSceneColor);

        for (int32 AfterPassCallbackIndex = 0; AfterPassCallbackIndex < PassCallbacks.Num(); AfterPassCallbackIndex++)
        {
            InOutPostProcessAfterPassInputs.SetInput(EPostProcessMaterialInput::SceneColor, InSceneColor);

            FAfterPassCallbackDelegate& AfterPassCallback = PassCallbacks[AfterPassCallbackIndex];
            PassSequence.AcceptOverrideIfLastPass(InPass, InOutPostProcessAfterPassInputs.OverrideOutput, AfterPassCallbackIndex);
            InSceneColor = AfterPassCallback.Execute(GraphBuilder, View, InOutPostProcessAfterPassInputs);
        }
    }
};

AddAfterPass에서 FScreenPassTexture InSceneColor가 "SceneColor"의 참조를 오버라이드된 델리게이트에 전달하는 것을 볼 수 있습니다. SceneColor는 뷰포트 사각형과 쌍을 이루는 텍스처를 설명하는 FScreenPassTexture입니다. Scene Color 텍스처는 후처리 파이프라인 전반에 걸쳐 여러 번 기록되며, 우리가 삼각형을 그릴 텍스처가 될 것입니다. 이것이 우리의 "렌더 타겟"이 될 것입니다.

Class Setup

자, 이제 우리 플러그인의 public/private 소스 폴더 안에 또 다른 클래스 헤더와 CPP 파일을 만들어 봅시다. 이름을 MyViewExtension (또는 원하는 아무거나)으로 지정하세요.

#pragma once

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

class YOURPLUGINNAME_API FMyViewExtension : public FSceneViewExtensionBase {

public:

    FMyViewExtension(const FAutoRegister& AutoRegister);

    //~ FSceneViewExtensionBase 인터페이스 시작
    virtual void SetupViewFamily(FSceneViewFamily& InViewFamily) override {};
    virtual void SetupView(FSceneViewFamily& InViewFamily, FSceneView& InView) override {};
    virtual void BeginRenderViewFamily(FSceneViewFamily& InViewFamily) override;
    virtual void PreRenderViewFamily_RenderThread(FRDGBuilder& GraphBuilder, FSceneViewFamily& InViewFamily) override{};
    virtual void PreRenderView_RenderThread(FRDGBuilder& GraphBuilder, FSceneView& InView) override;
    virtual void PostRenderBasePass_RenderThread(FRHICommandListImmediate& RHICmdList, FSceneView& InView) override {};
    virtual void PrePostProcessPass_RenderThread(FRDGBuilder& GraphBuilder, const FSceneView& View, const FPostProcessingInputs& Inputs) override;
    virtual bool SubscribeToPostProcessingPass(EPostProcessingPass Pass, FAfterPassCallbackDelegateArray& InOutPassCallbacks, FInputsStruct& InOutInputs) override;
    virtual bool IsEnabled() const override; // This method is not in the provided image but usually part of FSceneViewExtensionBase. Add it for completeness if needed.
    //~ FSceneViewExtensionBase 인터페이스 끝
};

여기서 우리는 _RenderThread로 표시된 몇 가지 델리게이트와 다른 설정 함수들을 가지고 있습니다. 우리는 "SubscribeToPostProcessingPass"만 오버라이딩할 것입니다.

MyViewExtension.cpp 파일에 몇 가지 함수를 설정해 봅시다.

#include "ViewExtension.h"
#include "TriangleShader.h"
#include "PixelShaderUtils.h"
#include "PostProcess/PostProcessing.h"
#include "PostProcess/PostProcessMaterial.h"
#include "SceneTextureParameters.h"
#include "ShaderParameterStruct.h"

// 이 줄은 렌더 패스의 이름을 선언하여 렌더 디버거에서 볼 수 있도록 합니다.
DECLARE_GPU_DRAWCALL_STAT(TrianglePass);

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

// FlensFlareScene 뷰 시작
void FMyViewExtension::SubscribeToPostProcessingPass(EPostProcessingPass Pass, FAfterPassCallbackDelegateArray& InOutPassCallbacks, bool bIsEnabled)
{
    if (Pass == EPostProcessingPass::Tonemap)
    {
        // 여기에 Raw Delegate를 생성합니다. 나중에 살펴보세요.
    }
}

SubscribeToPostProcessingPass 함수 내의 if 문에 주목하고 싶습니다. 이 if 문은 EPostProcessing 열거형을 사용하여 후처리(Post Processing) 단계의 어느 지점에 렌더 패스를 삽입할지 정의합니다. 저는 톤맵(Tonemap) 후에 삽입하기로 선택했지만, 해당 열거형으로 정의된 파이프라인의 다른 지점을 선택할 수도 있습니다. 현재로서는 전체 후처리 패스 동안 사용할 수 있는 씬 컬러(Scene Color)에 삼각형을 그리는 것이 목표입니다.


Setting up Global Shaders

다음 단계는 두 개의 전역 셰이더를 생성하는 것입니다.

하나는 정점 버퍼에 저장된 삼각형 정점 데이터를 처리하는 커스텀 Vertex shader입니다.

두 번째 셰이더는 래스터라이저가 정점을 처리한 후 삼각형에 색을 입힐 Pixel shader입니다.

다시 말해, 플러그인 소스 폴더 안에 별도의 cpp/헤더 파일을 생성합니다.

TriangleShader.cpp와 TriangleShader.h입니다.

Vertex Shader Class

// TriangleShader.h
// 뷰 확장(View Extension)에서 접근할 수 있도록 여기에 정의합니다.
BEGIN_SHADER_PARAMETER_STRUCT(FTriangleVsParams,)
    //RENDER_TARGET_BINDING_SLOTS()
END_SHADER_PARAMETER_STRUCT()

class FTriangleVS : public FGlobalShader
{
public:
    DECLARE_GLOBAL_SHADER(FTriangleVS);
    SHADER_USE_PARAMETER_STRUCT(FTriangleVS, FGlobalShader)
    using FParameters = FTriangleVsParams;

    static bool ShouldCompilePermutation(const FGlobalShaderPermutationParameters& Parameters) {
        return true;
    }
};

간단하게 유지하기 위해 버텍스 셰이더를 위한 새롭고 복잡한 매크로 특정 버퍼나 리소스를 생성하지 않을 것입니다. RDG는 최소한 매크로 선언을 요구합니다.

Pixel Shader Class

// TriangleShader.h
BEGIN_SHADER_PARAMETER_STRUCT(FTrianglePSParams,)
    RENDER_TARGET_BINDING_SLOTS()
END_SHADER_PARAMETER_STRUCT()

class FTrianglePS : public FGlobalShader
{
public:
    DECLARE_GLOBAL_SHADER(FTrianglePS);
    using FParameters = FTrianglePSParams;
    SHADER_USE_PARAMETER_STRUCT(FTrianglePS, FGlobalShader)
};

RENDER_TARGET_BINDING_SLOTS()는 우리가 전달하는 유일한 리소스입니다. 우리는 뷰포트 정보 또는 렌더 타겟을 커스텀 셰이더 HLSL 코드에 바인딩하고 있습니다.

두 클래스 모두에서 이를 전역 셰이더로 선언하고 셰이더가 필요로 하는 로컬 파라미터 구조체를 정의해야 합니다.

C++에서 "using"은 다른 곳에서 해당 타입의 포인터를 선언할 때 FParameters 타입으로 "FTrianglePSParams"에 접근할 수 있도록 해줍니다.

이제 셰이더 폴더에 Triangle HLSL 코드를 추가해 봅시다.

셰이더 폴더로 가서 "Triangle.usf"라는 파일을 만들고 아래 코드를 붙여넣은 다음 저장하세요.

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

void TriangleVS(
    in float2 InPosition : ATTRIBUTE0,
    out float4 InColor : ATTRIBUTE1,
    out float4 OutPosition : SV_POSITION,
    out float4 OutColor : COLOR0
)
{
    OutPosition = float4(InPosition, 0, 1);
    OutColor = InColor;
}

void TrianglePS(
    in float4 InPosition : SV_POSITION,
    in float4 InColor : COLOR0,
    out float4 OutColor : SV_Target0
)
{
    OutColor = InColor;
}

Creating Index and Vertex Buffers

이 시점에서 우리는 렌더링 패스에 관련된 거의 모든 클래스를 소개했습니다. 우리는 아직 정점 버퍼(Vertex Buffer)와 인덱스 버퍼(Index Buffer)를 위한 리소스 클래스를 정의해야 합니다.

TrangleShader.h 파일에 컬러 정점(Colored Vertex)을 정의하는 구조체를 추가합니다.

/** 텍스처를 필터링하는 데 사용되는 정점 데이터입니다. */
// TrangleShader.h 
struct FColorVertex 
{ 
public: 
	FVector2f Position; 
	FVector4f Color; 
};

Vertex Buffer 추가하기

FVertexBuffer class를 추가합니다.

// TriangleShader.h
/**
 * 2D 화면 사각형에 사용되는 정적 정점 및 인덱스 버퍼입니다.
 */
struct FColorVertex
{
public:
    FVector2f Position;
    FVector4f Color;
};

class FTriangleVertexBuffer : public FVertexBuffer
{
public:
    // 이 렌더링 리소스에 대한 RHI를 초기화합니다.
    void InitRHI() override
    {
        TResourceArray<FColorVertex, VERTEXBUFFER_ALIGNMENT> Vertices;
        Vertices.SetNumUninitialized(3);

        Vertices[0].Position = FVector2f(0.0f,0.75f);
        Vertices[0].Color = FVector4f(1, 0, 0, 1);

        Vertices[1].Position = FVector2f(-0.75, -0.75);
        Vertices[1].Color = FVector4f(0, 1, 0, 1);

        Vertices[2].Position = FVector2f(0.75, -0.75);
        Vertices[2].Color = FVector4f(0, 0, 1, 1);

        FRHIResourceCreateInfo CreateInfo(TEXT("FScreenRectangleVertexBuffer"), &Vertices);
        VertexBufferRHI = RHICreateVertexBuffer(Vertices.GetResourceDataSize(), BUF_STATIC, CreateInfo);
    }
};

FTriangleVertexBuffer 안에서 InitRHI를 오버라이드하여 정점 데이터를 초기화합니다. GPU 프로그래밍에서는 CPU의 리소스를 GPU로 메모리 복사하기 전에 바인딩할 수 있도록 컨텍스트를 생성해야 합니다. 이를 달성하기 위해서는 리소스의 이름, 크기 및 기타 속성을 포함하는 컨텍스트를 지정해야 합니다. VertexBufferRHI 객체를 RHICreateVertexBuffer로 설정하고 필요한 정보를 전달합니다.

Index Buffer 추가하기

// TrangleShader.h 
class FTriangleIndexBuffer : public FIndexBuffer
{
public:
    // 이 렌더링 리소스에 대한 RHI를 초기화합니다.
    void InitRHI() override
    {
        const uint16 Indices[] = { 0, 1, 2 };

        TResourceArray<uint16, INDEXBUFFER_ALIGNMENT> IndexBuffer;
        uint32 NumIndices = UE_ARRAY_COUNT(Indices);
        IndexBuffer.AddUninitialized(NumIndices);
        FMemory::Memcpy(IndexBuffer.GetData(), Indices, NumIndices * sizeof(uint16));

        FRHIResourceCreateInfo CreateInfo(TEXT("FTriangleIndexBuffer"), &IndexBuffer);
        IndexBufferRHI = RHICreateIndexBuffer(sizeof(uint16), IndexBuffer.GetResourceDataSize(), BUF_STATIC, CreateInfo);
    }
};

이전과 동일한 과정으로, 삼각형처럼 간단한 것에는 인덱스 버퍼를 사용할 필요가 없습니다. 인덱스 버퍼는 정점 버퍼에 있는 정점 데이터에 대한 포인터를 가지고 있습니다. 일반적으로 인덱스 버퍼는 세 개의 정점 조합을 읽지만, 원하는 어떤 조합이든 만들 수 있습니다. 이는 사용 사례에 따라 삼각형, 사각형 또는 다른 기본 도형을 그리기 위해 정점 데이터를 재사용할 수 있으므로 최적입니다.

마지막으로 정점 버퍼에 대한 전역 선언 리소스를 추가합니다. 이는 입력 어셈블러에 대한 입력 레이아웃을 정의하는 데 사용되므로 HLSL 코드에서 올바른 속성을 바인딩할 수 있습니다.

// TriangleShader.h
class FTriangleVertexDeclaration : public FRenderResource
{
public:
    FVertexDeclarationRHIRef VertexDeclarationRHI;

    /** Destructor. */
    virtual ~FTriangleVertexDeclaration() {}

    virtual void InitRHI()
    {
        FVertexDeclarationElementList Elements;
        uint16 Stride = sizeof(FColorVertex);
        Elements.Add(FVertexElement(0, STRUCT_OFFSET(FColorVertex, Position), VET_Float2, 0, Stride));
        Elements.Add(FVertexElement(0, STRUCT_OFFSET(FColorVertex, Color), VET_Float4, 1, Stride));
        VertexDeclarationRHI = PipelineStateCache::GetOrCreateVertexDeclaration(Elements);
    }

    virtual void ReleaseRHI()
    {
        VertexDeclarationRHI.SafeRelease();
    }
};

다시 InitRHI를 오버라이드하여 선언 정보를 설정합니다. 입력 레이아웃의 경우, 엘리먼트 객체와 스트라이드를 정의합니다. 컴퓨터 그래픽스에서 스트라이드는 메모리에 저장된 엘리먼트 배열에서 한 엘리먼트의 시작과 다음 엘리먼트의 시작 사이의 바이트 수를 나타냅니다. 입력 어셈블러가 정점 버퍼를 올바르게 읽으려면 한 정점의 시작과 다음 정점의 시작 사이의 바이트 수를 알아야 합니다. sizeof 함수를 사용하여 한 정점의 공간을 다음 정점의 오프셋으로 계산할 수 있습니다. 또한 STRUCT_OFFSET은 구조체에 정의된 값들 사이의 오프셋을 결정하는 매크로 헬퍼입니다.

다음으로 리소스를 전역으로 선언하고 extern으로 지정하여 렌더러 API가 이를 볼 수 있도록 합니다.

// TriangleShader.h 
extern YOURPLUGIN_API TGlobalResource<FTriangleVertexBuffer> GTriangleVertexBuffer; 
extern YOURPLUGIN_API TGlobalResource<FTriangleIndexBuffer> GTriangleIndexBuffer; 
extern YOURPLUGIN_API TGlobalResource<FTriangleVertexDeclaration> GTriangleVertexDeclaration;

Triangle.cpp로 돌아가서 HLSL의 함수 이름과 일치하도록 셰이더의 시작점을 선언합니다.

#include "TriangleShader.h"
#include "Shader.h"
#include "VertexFactory.h"

// 정점 셰이더 및 픽셀 셰이더의 시작점을 정의합니다.
// 모든 셰이더에 필요합니다.
IMPLEMENT_SHADER_TYPE(, FTriangleVS, TEXT("/CustomShaders/Triangle.usf"), TEXT("TriangleVS"), SF_Vertex);
IMPLEMENT_SHADER_TYPE(, FTrianglePS, TEXT("/CustomShaders/Triangle.usf"), TEXT("TrianglePS"), SF_Pixel);

TGlobalResource<FTriangleVertexBuffer> GTriangleVertexBuffer;
TGlobalResource<FTriangleIndexBuffer> GTriangleIndexBuffer;
TGlobalResource<FTriangleVertexDeclaration> GTriangleVertexDeclaration;

마지막으로 TGlobalResources 객체들을 정의합니다.


Adding an RDG Pass

MyViewExtension .cpp/.h 파일을 업데이트하기 위해 돌아갑니다.

// MyViewExtension.h
class YOURPLUGINNAME_API FMyViewExtension : public ViewExtension
{
public:
    FMyViewExtension(FAutoRegister& AutoRegister);
    virtual bool SubscribeToPostProcessingPass(EPostProcessingPass Pass, FAfterPassCallbackDelegateArray& InOutPassCallbacks, bool bIsEnabled) override;

protected:
    // PixelShaderUtils에서 복사됨
    template <typename TShaderClass>
    static void AddFullscreenPass(
        FRDGBuilder& GraphBuilder,
        FRHIGlobalShaderMap* GlobalShaderMap,
        const TShaderRef<TShaderClass>& PixelShader,
        const typename TShaderClass::FParameters* Parameters,
        FRHIBlendState* BlendState = nullptr,
        FRHIRasterizerState* RasterizerState = nullptr,
        FRHIDepthStencilState* DepthStencilState = nullptr,
        uint32 StencilRef = 0
    );

    template <typename TShaderClass>
    static void DrawFullscreenPixelShader(
        FRHICommandList& RHICmdList,
        FRHIGlobalShaderMap* GlobalShaderMap,
        const TShaderRef<TShaderClass>& PixelShader,
        const typename TShaderClass::FParameters* Parameters,
        const FIntRect& Viewport,
        FRHIBlendState* BlendState = nullptr,
        FRHIRasterizerState* RasterizerState = nullptr,
        FRHIDepthStencilState* DepthStencilState = nullptr,
        uint32 StencilRef = 0
    );

    static inline void DrawFullscreenTriangle(FRHICommandList& RHICmdList, uint32 InstanceCount)
    {
        RHICmdList.SetStreamSource(0, GTriangleVertexBuffer.VertexBufferRHI, 0);
        RHICmdList.DrawIndexedPrimitive(
            GTriangleIndexBuffer.IndexBufferRHI, 
            /*BaseVertexIndex=*/0, 
            /*MinIndex=*/0,
            /*NumVertices=*/3, 
            /*StartIndex=*/0, 
            /*NumPrimitives=*/1, 
            /*NumInstances=*/InstanceCount
        );
    }

    // Tone mapper pass가 끝날 때 호출되는 델리게이트입니다.
    FScreenPassTexture TrianglePass_RenderThread(FRDGBuilder& GraphBuilder, const FSceneView& View, const FPostProcessMaterialInputs& Inputs);

// 일단은 정점 버퍼를 만들어서 뭔가 넣을 수 있는지 확인해봅니다.
public:
    static void RenderTriangle(
        FRDGBuilder& GraphBuilder,
        FRHIGlobalShaderMap* ViewShaderMap,
        const FIntRect& View,
        FScreenPassTexture& SceneColor
    );
};

순서를 기억하십시오. 먼저 RDG 람다 함수에 필요한 리소스와 매개변수를 제공하여 패스를 추가하는 것으로 시작합니다. 다음은 GPU 파이프라인을 설정하는 드로우 콜(draw call)을 정의하는 것입니다. GPU 파이프라인은 드로우 콜에 필요한 모든 매개변수(블렌드 스테이트, 래스터라이저 스테이트, 프리미티브 타입, 셰이더, 뷰포트, 렌더 타겟, 커맨드)를 정의하므로 반드시 설정해야 합니다. GPU 파이프라인은 사용되는 저수준 그래픽스 API 호출을 위해 해석될 상태를 저장합니다.

첫 번째 함수는 람다 함수에 필요한 매개변수를 가진 템플릿화된 AddFullscreenPass이며, 드로우 콜에 사용하지 않을 매개변수는 널(null) 처리합니다.

// MyExtension.cpp
template <typename TShaderClass>
void FMyExtension::AddFullscreenPass(
    FRDGBuilder& GraphBuilder,
    const FGlobalShaderMap* GlobalShaderMap,
    FRDGEventName&& PassName,
    const TShaderRef<TShaderClass>& PixelShader,
    typename TShaderClass::FParameters* Parameters,
    const FIntRect& Viewport,
    FRHIBlendState* BlendState,
    FRHIRasterizerState* RasterizerState,
    FRHIDepthStencilState* DepthStencilState,
    uint32 StencilRef)
{
    check(PixelShader.IsValid());
    ClearUnusedGraphResources(PixelShader, Parameters);

    GraphBuilder.AddPass(
        Forward<FRDGEventName>(PassName),
        Parameters,
        ERDGPASS_Flags::Raster,
        [Parameters, GlobalShaderMap, PixelShader, Viewport, BlendState, RasterizerState, DepthStencilState, StencilRef]
        (FRHICommandList& RHICmdList)
    {
        FCanvasSceneView::DrawFullscreenPixelShader<TShaderClass>(RHICmdList, GlobalShaderMap, PixelShader,
            Parameters,
            Viewport,
            BlendState, RasterizerState, DepthStencilState, StencilRef);
    });
}

Setting up the Draw Call

// MyExtension.cpp
template <typename TShaderClass>
void FMyExtension::DrawFullscreenPixelShader(
    FRHICommandList& RHICmdList,
    const FGlobalShaderMap* GlobalShaderMap,
    const TShaderRef<TShaderClass>& PixelShader,
    typename TShaderClass::FParameters* Parameters,
    const FIntRect& Viewport,
    FRHIBlendState* BlendState,
    FRHIRasterizerState* RasterizerState,
    FRHIDepthStencilState* DepthStencilState,
    uint32 StencilRef)
{
    check(PixelShader.IsValid());
    RHICmdList.SetViewport((float)Viewport.Min.X, (float)Viewport.Min.Y, 0.0f, (float)Viewport.Max.X, (float)Viewport.Max.Y, 1.0f);

    // Begin Setup Gpu Pipeline for this Pass
    FGraphicsPipelineStateInitializer GraphicsPSOInit;
    TShaderMapRef<FScreenVS> VertexShader(GlobalShaderMap);

    RHICmdList.ApplyCachedRenderTargets(GraphicsPSOInit);
    GraphicsPSOInit.BlendState = TStaticBlendState<>::GetRHI();
    GraphicsPSOInit.RasterizerState = TStaticRasterizerState<false>::GetRHI();
    GraphicsPSOInit.DepthStencilState = TStaticDepthStencilState<false, CF_Always>::GetRHI();

    GraphicsPSOInit.BoundShaderState.VertexDeclarationRHI = GTriangleVertexDeclaration.VertexDeclarationRHI;
    GraphicsPSOInit.BoundShaderState.VertexShaderRHI = VertexShader.GetVertexShader();
    GraphicsPSOInit.BoundShaderState.PixelShaderRHI = PixelShader.GetPixelShader();
    GraphicsPSOInit.PrimitiveType = PT_TriangleList;

    GraphicsPSOInit.BlendState = BlendState ? BlendState : GraphicsPSOInit.BlendState;
    GraphicsPSOInit.RasterizerState = RasterizerState ? RasterizerState : GraphicsPSOInit.RasterizerState;
    GraphicsPSOInit.DepthStencilState = DepthStencilState ? DepthStencilState : GraphicsPSOInit.DepthStencilState;
    // End Gpu Pipeline setup
    SetGraphicsPipelineState(RHICmdList, GraphicsPSOInit, StencilRef);
    SetShaderParameters(RHICmdList, PixelShader, PixelShader.GetPixelShader(), Parameters);
    DrawFullscreenTriangle(RHICmdList, 1);
}

렌더 트라이앵글(RenderTriangle) 함수를 생성하여 애드 패스(Add Pass) 및 드로우 콜(Draw Call) 함수를 래핑했습니다.

// MyViewExtension.cpp
void FMyViewExtension::RenderTriangle(
    FRDGBuilder& GraphBuilder,
    const FGlobalShaderMap* ViewShaderMap,
    const FIntRect& ViewInfo,
    const FScreenPassTexture& SceneColor)
{
    // 설정 시작
    // 셰이더 파라미터 설정
    FTrianglePSParams* PassParams = GraphBuilder.AllocParameters<FTrianglePSParams>();
    // 이 경우 렌더 타겟을 씬 컬러로 설정
    PassParams->RenderTargets[0] = FRenderTargetBinding(SceneColor.Texture, ERenderTargetLoadAction::ENoAction);

    // FTrianglePS 픽셀 셰이더 생성
    TShaderMapRef<FTrianglePS> PixelShader(ViewShaderMap);

    // 패스 추가
    AddFullscreenPass<FTrianglePS>(GraphBuilder,
        ViewShaderMap,
        RDG_EVENT_NAME("TrianglePass"),
        PixelShader,
        PassParams,
        ViewInfo);
}

Binding The Delegate

TrianglePass_RenderThread 함수를 생성

// MyViewExtension.cpp
FScreenPassTexture FMyViewExtension::TrianglePass_RenderThread(FRDGBuilder& GraphBuilder, const FSceneView& View, const FPostProcessMaterialInputs& InOutInputs)
{
    const FScreenPassTexture SceneColor = InOutInputs.GetInput(EPostProcessMaterialInput::SceneColor);

    RDG_GPU_STAT_SCOPE(GraphBuilder, TrianglePass);
    RDG_EVENT_SCOPE(GraphBuilder, "TrianglePass");

    // FSceneView를 FViewInfo로 캐스팅
    const FIntRect ViewInfo = static_cast<const FViewInfo&>(View).ViewRect;
    const FGlobalShaderMap* ViewShaderMap = static_cast<const FViewInfo&>(View).ShaderMap;

    RenderTriangle(GraphBuilder, ViewShaderMap, ViewInfo, SceneColor);

    return SceneColor;
}

RenderTriangle을 호출하기 전에 SceneColor 텍스처를 가져와야 하며 몇 가지 static_cast를 수행해야 합니다. 하나는 뷰포트 크기용이고 다른 하나는 전역 셰이더 맵에 대한 포인터용입니다. 전역 셰이더 맵은 전역 매크로를 사용하여 선언한 사용자 지정 셰이더 객체를 가리킵니다.

마지막으로 이전에 언급했던 if 문 안에 델리게이트 함수 TrianglePass_RenderThread를 추가합니다.

void FLensFlareSceneView::SubscribeToPostProcessingPass(EPostProcessingPass Pass, FAfterPassCallbackDelegateArray& InOutPassCallbacks, bool bIsEnabled)
{
    if (Pass == EPostProcessingPass::Tonemap)
    {
        InOutPassCallbacks.Add(FAfterPassCallbackDelegate::CreateRaw(this, &FLensFlareSceneView::TrianglePass_RenderThread));
    }
}

이제 컴파일하세요! 에디터 뷰포트에 삼각형이 그려진 것을 볼 수 있을 것입니다.

축하합니다. 언리얼 엔진의 RDG를 사용하여 첫 번째 삼각형을 그렸습니다!


5. RDG Insights

Insights는 Render Dependency 그래프를 프로파일링, 추적 및 시각화하는 데 적극 권장하는 도구입니다. 언리얼 엔진 내에서 프로젝트에 RDG Insights 플러그인을 활성화하는 방법이 있습니다. 이는 패스가 어떻게 연관되어 있는지, 리소스 수명이 해당 패스에 어떻게 연결되어 있는지, 또는 잠재적으로 풀링된 리소스 중복을 추적할 수 있기 때문에 매우 유용합니다.

이것이 중요한 이유는 렌더 파이프라인의 특정 지점에서 자신의 패스를 바인딩하기 위해 View Extension 클래스를 델리게이트로 사용하는 경우 이전 패스에서 필요한 리소스가 패스의 입력 리소스로 사용 불가능할 수 있기 때문입니다. 또한 모든 리소스를 추적할 수 있으므로 다음과 같은 몇 가지 사항을 확인할 수 있습니다.

  1. 비동기 컴퓨팅 패스가 중복되지 않는 이유는 무엇입니까?
  2. 프레임 전체에서 리소스가 어떻게 사용됩니까?
  3. 내 리소스 할당이 다른 리소스와 중복됩니까?
  4. 어떤 리소스가 후처리에서 사용됩니까?
  5. 내 패스가 컬링됩니까?

다음은 실행 중인 모습의 시각적 예시입니다.

이 기능을 활성화하려면 프로젝트를 시작하고 메인 메뉴에서 '편집(Edit) > 플러그인(Plugins) > Insights'를 선택하여 RDG Insights 플러그인을 활성화하십시오.

Insights를 실행하려면 프로젝트와 동일한 구성으로 빌드되었는지 확인해야 합니다. 소스에서 빌드했든 아니든 실행 파일은 언리얼 엔진이 있는 "Binaries" 폴더 안에 있어야 합니다.

"..UnrealEngine5.1\Engine\Binaries\Win64\UnrealInsights-Win64-XX.exe" 와 같이 XX는 컴파일 방식에 따라 Debug, Developer가 될 수 있습니다.

프로젝트를 먼저 시작한 다음 Insights를 시작하십시오. 실행 중인 라이브 프로세스에 연결될 것입니다. 하단에 RDG 트랙이 보일 것입니다. 프레임을 확대하여 확인하십시오.

경고: RDG의 라이브 캡처를 실행하면 많은 데이터를 캡처합니다. 너무 오래 실행하지 말고 필요한 부분만 캡처하고 닫는 것을 권장합니다.


6. 참고

이 모든 자료를 조합하기 위해 엔진 소스 코드를 파고들고 기사를 검색하는 데 시간이 좀 걸렸습니다. 이 섹션에서는 북마크한 참고 자료들을 학습 순서대로 연결해 둘 것입니다. 이렇게 하면 언리얼 엔진의 렌더링과 그래픽 프로그래밍의 기초를 이해하는 데 필요한 지식을 쌓아가는 순서대로 이 문서를 검토할 수 있습니다.

Basic Graphics Programming References:

Unreal Engine Passes & Rendering Basics:

Unreal Engine RHI

Unreal Engine Render Dependency Graph

Scene View Extension Class

RDG Insights

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

0개의 댓글