2025. 4. 10. 16:23ㆍ개인 공부
- 2025/04/10 (최초 작성)
- 2025/04/10 (마지막 수정)
발 번역 주의보, Figures나 예제 코드는 빠져있을 수 있습니다.
개인 공부 및 연습용으로 번역된 글입니다. 의역/오역/오타가 많을 수 있으며. 원저작권자의 별도의 허가 없이 작성되었으므로, 언제든지 삭제될 수 있습니다.
원본 아티클
언리얼 엔진 4 렌더링 파트 2: 셰이더와 정점 데이터
by Matt Hoffman
(아직 이 시리즈의 파트 1을 읽어보지 않으셨다면, 여기서 확인해 보실 수 있습니다)
셰이더와 정점 팩토리
이번 포스트에서는 셰이더와 정점 팩토리에 대해 알아보도록 하겠습니다. 언리얼은 셰이더의 C++ 표현을 동등한 HLSL 클래스로 바인드 하는 마법과 정점 셰이더가 필요한 데이터를 GPU 업로드걸 제어하기 위해 정점 팩토리를 사용합니다. 이 포스트에서는 여러분이 더 쉽게 살펴볼 수 있도록 언리얼의 클래스 명을 사용해서 설명해 보도록 하겠습니다.
먼저 셰이더/정점 팩토리와 관련된 주요 클래스들 에만 집중 할 것입니다. 이 전체 시스템을 하나로 묶는 접착제 역할을 하는 많은 보조적인 역할을 하는 연관된 구조체와 함수들이 존재합니다. 이런 연결 요소들을 직접 변경해야 할 가능성은 낮기 때문에 굳이 이들에 대해 설명하여 여러분을 혼란스럽게 만들지 않을 것입니다!
셰이더
언리얼에 존재하는 모든 셰이더는 FShader라는 베이스 클래스를 상속합니다. 언리얼은 셰이더를 단일 인스턴스만 존재해야 하는 경우엔 FGlobalShader로, 그리고 재질과 묶이는 셰이더를 FMaterialShader 경우 두 가지로 구분합니다. FShader는 특정 셰이더와 연관된 GPU에서의 리소스에 대한 추적을 유지하는 FShaderResource와 묶입니다. FShader를 컴파일한 결과가 이미 존재하는 FShaderResource와 일치한다면, FShaderResource는 여러 FShader에 걸쳐서 공유될 수 있습니다.
FGlobalShader
이 종류의 셰이더는 꽤 간단하지만 한정된 사용처를 가집니다(하지만 효과적이죠!). 셰이더 클래스가 FGlobalShader를 상속하면 해당 클래스는 전역 리컴파일 그룹의 일부가 됩니다 (이는 엔진이 실행되고 있는 동안에는 다시 컴파일되지 않는다는 것을 의미합니다!). 전역 셰이더는 오직 하나의 인스턴스만 존재하므로, 인스턴스별 파라미터를 가질 수 없습니다. 반면, 전역 파라미터는 사용할 수 있습니다. 예시: FLensDistortionUVGenerationShader, FBaseGPUSkinCacheCS (메시 스키닝을 계산하기 위한 컴퓨트 셰이더), 그리고 FSimpleElementVS/FSimpleElementPS.
FMaterialShader와 FMeshMaterialShader
자 이제 좀 더 복잡한 FMaterialShader와 FMeshMaterialShader를 살펴보겠습니다. 두 클래스 모두 여러 인스턴스를 가질 수 있고, 각 인스턴스는 자신들만의 GPU 리소스와 연관될 수 있습니다. FMaterialShader는 바인드 된 HLSL 파라미터의 값을 변경할 수 있는 SetParameters 함수를 제공합니다. 파라미터 바인딩은 FShaderParameter/FShaderResourceParameter 클래스들에 의해 이루어지며, 셰이더의 생성자에서 수행됩니다. 예시로 FSimpleElementPS 클래스를 참고하세요.
SetParameters 함수는 실행하고자 하는 셰이더를 사용하여 뭔가를 렌더링 하기 직전에 호출되어 변경하고 싶은 파라미터의 계산에 활용될 상당한 양의 정보를 제공하는 머테리얼을 포함한 정보를 전달합니다.
이제 셰이더 측면에서의 파라미터를 어떻게 설정하는지 알아봤으니, FMeshMaterialShader에 대해 살펴보도록 합시다. FMeshMaterialShader는 각 메시를 그리기 전에 셰이더에 있는 파라미터를 설정할 능력을 제공합니다. 재질과 정점 팩토리 파라미터가 필요한 모든 클래스들의 베이스 클래스이기 때문에 상당수의 셰이더들이 FMeshMaterialShader를 상속합니다. FMeshMaterialShader는 각 메시가 셰이더를 사용해서 그려지기 직전에 SetMesh 함수를 호출하여 특정 메시에 맞게 GPU의 파라미터를 수정할 수 있도록 합니다.
예시: TDepthOnlyVS, TBasePassVS, TBasePassPS
C++을 HLSL로 바인딩하기
FShader가 CPU에서의 셰이더에 대한 C++ 표현이란 것을 알았으니, 이제 주어진 FShader가 관련된 HLSL 코드와 어떻게 연결되는지 알아봐야 합니다. 여기서 첫 번째 C++ 매크로가 등장합니다: IMPLEMENT_MATERIAL_SHADER_TYPE(TemplatePrefix, ShaderClass, SourceFileName, FunctionName, Frequency). 각 파라미터에 대해 설명하기 전에 DepthRendering.cpp를 예시로 살펴보도록 하겠습니다.
IMPLEMENT_MATERIAL_SHADER_TYPE(,FDepthOnlyPS,TEXT(“/Engine/Private/DepthOnlyPixelShader.usf”),TEXT(“Main”),SF_Pixel);
이 매크로는 C++ 클래스인 FDepthOnlyPS를 /Engine/Private/DepthOnlyPixelShader.usf에 위치한 HLSL 코드에 바인드 합니다. 특히, 엔트리 포인트가 "Main"이고, SF_PIXEL의 빈도(frequency)를 가진다는 것을 명시합니다. 자 이제 C++ 코드 (FDepthOnlyPS)와, HLSL 파일이 (DepthOnlyPixelShader.usf)에 존재하고 HLSL 코드에서 어떤 함수를 호출하여야 하는지(Main) 연관 지었습니다. 언리얼은 Vertex, Hull, Domain, Geometry, Pixel이나 Compute 중 어떤 셰이더 타입인지를 명시하기 위해 "빈도(Frequency)"라는 용어를 사용합니다.
위 구현에서는 첫 번째 인자를 무시했다는 것을 눈치채셨을 것입니다. 이렇게 한 이유는 이 특정한 예시가 템플릿 함수가 아니기 때문입니다. 어떤 경우엔 해당 매크로는 템플릿 함수를 특수화하며, 이 템플릿 클래스는 특정한 구현을 만들어내기 위해 또 다른 매크로에 의해 인스턴스화됩니다.
이 경우에 대한 예시로 가능한 각 라이팅 타입에 대한 변형을 만드는 것입니다. 만약 관심이 있으시다면 BasePassRendering.cpp의 거의 윗부분에서 IMPLEMENT_BASEPASS_LIGHTMAPPED_SHADER_TYPE 매크로를 찾아보세요.. 물론 나중에 Base Pass 아티클에서 이에 대해 더 자세히 다룰 것입니다!
리뷰
FShader의 구현은 셰이딩 파이프라인 내에서 특정한 단계입니다 그리고 단계들이 사용되기 이전에 HLSL 코드 내부에 있는 파라미터를 수정할 수 있습니다. 언리얼은 C++ 코드를 HLSL 코드로 바인드 하기 위해서 매크로를 사용합니다. 셰이더를 처음부터 새롭게 구현하는건 아주 간단하지만, 그 셰이더를 이미 존재하는 디퍼드 베이스 패스 또는 셰이딩에 통합시키고자 한다면 좀 더 복잡해지게 됩니다.
캐싱과 컴파일 환경
더 나아가기 전에 두 가지 중요한 개념들에 대해 소개하도록 하겠습니다. 언리얼은 여러분이 머테리얼을 수정할 때 아주 아주 많은 가능한 셰이더의 조합들을 자동으로 컴파일합니다. 좋은 일 같지만 상당수의 사용하지 않는 셰이더를 만들어 내는 결과로 이어질 수 있습니다. 이러한 점을 고려하여 ShouldCache 함수를 소개하겠습니다.
언리얼은 셰이더, 머테리얼 그리고 정점 팩토리 모두가 특정한 조합이 캐시 되어야 한다고 동의하는 경우에만 그 셰이더 조합을 생성합니다. 만약 하나라도 만족하지 않는다면 언리얼은 그 조합을 생성하지 않습니다, 이는 해당 조합이 함께 바인드 될 수 있는 상황에 절대 처하지 않을 것이라는 점을 암시합니다. 예를 들어 SM5 지원을 요구하는 셰이더가 있을 때 해당 셰이더를 캐시 하고 싶지 않을 때를 생각해 봅시다. 만약 여러분이 목표로 하는 플랫폼이 SM5를 지원하지 않는다면, 셰이더를 컴파일하거나 캐시 해둘 이유가 없습니다.
ShouldCache 함수는 FShader, FMaterial 또는 FVertexFactory에서 정적 함수로 구현됩니다. 실제 사례를 살펴보면 여러분이 ShouldCache 함수를 어떻게 구현해야 할지에 대한 아이디어를 얻을 수 있을 것입니다.
두 번째로 중요한 개념은 셰이더를 컴파일하기 전에 HLSL 코드 내부의 전처리 정의를 변경할 수 있는 능력입니다. FShader는 ModifyCompilationEnrivorment (매크로를 통해 구현되는 정적 함수)를 사용해서, FMaterial은 SetupMaterialEnvironment를 사용해서 마지막으로 FVertexFactory는 ModifyCompilationEnvironment를 사용합니다. 이 함수들은 셰이더가 컴파일되기 전에 호출되어 HLSL 전처리 정의들을 변경할 수 있도록 해줍니다. FMaterial은 불필요한 코드들을 최적화해내기 위해서 머테리얼에 설정된 설정값에 기반하여 셰이딩 모델 정의를 설정하는데 전적으로 이 함수들을 사용합니다.
FVertexFactory
자 이제 셰이더가 GPU로 이동하기 전에 어떻게 수정하는지에 대해 알아보았으니 어떻게 GPU에 데이터를 가져다 놓을지 알아봅시다! 정점 팩토리는 정점 데이터 소스를 캡슐화하고 정점 셰이더와 연결될 수 있습니다. 만약 여러분이 이전에 스스로 렌더링 코드를 작성해 본 적이 있으시다면 정점이 필요할 수 있는 모든 가능한 데이터를 담은 클래스를 만들고 싶으셨을 것입니다.
언리얼은 여러분이 실제로 필요한 데이터만 정점 버퍼에 업로드할 수 있도록 정점 팩토리를 대신 사용합니다.
정점 팩토리에 대해 이해하기 위해 두 가지 확실한 예제를 살펴보도록 하겠습니다. FLocalVertexFactory와 FGPUBaseSkinVertexFactory입니다.
FLocalVertexFactory는 명시적인 정점 어트리뷰트들을 로컬에서 월드 공간으로 변환하는 가장 간단한 방법을 제공하기 때문에 많은 곳에서 두루 사용됩니다. 정적 메시가 FLocalVertexFactory를 사용하고, 케이블과 절차적 메시들도 사용합니다. 스켈레탈 메시 (더 많은 데이터가 필요한)는 이와 반대로 FGPUBaseSkinVertexFactory를 사용합니다. 더 깊게 들어가서 서로 다른 데이터를 포함한 두 정점 팩토리와 셰이더 데이터와 어떻게 매치되는지 알아보도록 하겠습니다.
FPrimitiveSceneProxy
자 그래서 언리얼이 메시에 대해 어떤 정점 팩토리를 사용해야 하는지 어떻게 아는 걸까요? FPrimitiveSceneProxy 클래스를 살펴봅시다! FPrimitiveSceneProxy는 UPrimitiveComponent의 렌더 스레드 버전입니다. UPrimitiveComponent와 FPrimitiveSceneProxy 양쪽 모두 상속을 통해 특정한 구현을 만들어 내도록 유도하고 있습니다.
잠시 되돌아가서 설명하자면, 언리얼은 게임 스레드와 렌더 스레드를 가지고 있으며 두 스레드는 서로 다른 스레드의 데이터를 직접적으로 접근해서는 안 됩니다(특정한 동기화 매크로를 통한 경우를 제외하고). 이를 위해 언리얼은 게임 스레드를 위해 UPrimitiveComponent를 사용하고 CreateSceneProxy() 함수를 오버라이딩 함으로써 어떤 FPrimitiveSceneProxy 클래스를 생성할지 정합니다. FPrimitiveSceneProxy는 (적절한 시점에) 질의를 통해 게임 스레드에서 렌더 스레드로 데이터를 가져와 처리하여 GPU에 올릴 수 있습니다.
이 두 클래스는 종종 쌍(pair)으로 나타나며, 여기 훌륭한 두 예시가 있습니다.: UCable Component/FCableSceneProxy, 그리고 UImagePlateFrustrumComponent/FImagePlateFrustrumSceneProxy. FCableSceneProxy에서는 렌더 스레드가 UCableComponent에 있는 데이터를 참조하여 (위치, 색상, 기타 등등을 계산하여) 앞서 언급한 FLocalVertexFactory와 연결된 새로운 메시를 생성합니다.
UImagePlateFrustrumComponent는 흥미롭게도 정점 팩토리를 아예 가지고 있지 않습니다! 이 컴포넌트는 특정 데이터를 계산하기 위해 렌더 스레드의 콜백을 사용하고, 계산한 데이터를 사용하여 선을 그립니다. 이 과정에서 셰이더나 연결된 정점 팩토리가 전혀 사용되지 않고, GPU 콜백을 통해 직접 모드(immediate-mode) 스타일의 렌더링 함수를 호출할 뿐입니다.
C++을 HLSL에 바인딩하기
이제껏 서로 다른 타입의 정점 데이터와 어떻게 장면 내에 있는 컴포넌트가 데이터를 생성 및 저장하는지 (정점 팩토리를 가진 장면 프록시를 통해) 알아봤습니다. 이제 어떻게 GPU에서 고유한 정점 데이터를 사용하는지에 대해 알아야 합니다. 특히 베이스 패스에는 모든 다양한 유형의 입력 데이터를 처리하는 단 하나의 정점 함수만 있다는 점을 고려하면 더욱 그렇습니다! 답이 "또 다른 C++ 매크로"라고 추측하셨다면, 맞습니다!
IMPLEMENT_VERTEX_FACTORY_TYPE(FactoryClass, ShaderFilename,
bUsedWithmaterials, bSupportsStaticLighting,
bSupportsDynamicLighting, bPrecisePrevWorldPos,
bSupportsPositionOnly)
이 매크로는 정점 팩토리의 C++ 표현을 특정 HLSL 파일에 바인드 합니다. 예를 들어:
IMPLEMENT_VERTEX_FACTORY_TYPE(FLocalVertexFactory,
”/Engine/Private/LocalVertexFactory.ush”,true,true,true,true,true);
여기서 흥미로운 점을 찾을 수 있는데, 어디에도 엔트리 포인트가 지정되지 않았다는 것입니다 (또한 파일에서도 엔트리 포인트를 찾아볼 수 없습니다)! 제 생각엔 이것이 작동하는 방식은 정말이지 (배우기엔 혼란스럽지만) 매우 영리하다고 생각합니다: 언리얼은 동일한 이름을 유지하여 공통 코드들이 동작하게 하면서도, 어떤 정점 팩토리를 사용하느냐에 따라 자료 구조의 내용 함수 호출을 변경합니다.
예시를 먼저 살펴봅시다: BasePass 정점 셰이더는 FVertexFactoryInput을 입력으로 받습니다. 이 데이터 구조는 특별한 의미를 가지기 위해 LocalVertexFactory.ush에 정의되어있습니다. 하지만, GpuSkinVertexFactory.ush 또한 동일한 구조체를 정의합니다! 그렇다면, 어떤 헤더가 포함되느냐에 따라, 정점 셰이더에 제공되는 데이터는 바뀌게 됩니다. 이 패턴이 다른 영역에서도 반복돼서 등장하므로 셰이더 아키텍처 아티클에 대해서 이 점에 대해 더 자세히 다뤄보도록 하겠습니다.
// Entry point for the base pass vertex shader. We can see that it takes a generic FVertexFactoryInput struct and outputs a generic FBasePassVSOutput.
void Main(FVertexFactoryInput Input, out FBasePassVSOutput Output)
{
// This is where the Vertex Shader would calculate things based on the Input and store them in the Output.
}
// LocalVertexFactory.ush implements the FVertexFactoryInput struct
struct FVertexFactoryInput
{
float4 Position : ATTRIBUTE0;
float3 TangentX : ATTRIBUTE1;
float4 TangentZ : ATTRIBUTE2;
float4 Color : ATTRIBUTE3;
// etc…
}
// GpuSkinVertexFactory.ush also implements the FVertexFactoryInput struct
struct FVertexFactoryInput
{
float4 Position : ATTRIBUTE0;
half3 TangentX : ATTRIBUTE1;
half4 TangentZ : ATTRIBUTE2;
uint4 BlendIndices : ATTRIBUTE3;
uint4 BlendIndicesExtra : ATTRIBUTE14;
// etc…
}
리뷰
IMPLEMENT_MATERIAL_SHADER_TYPE 매크로는 셰이더의 엔트리 포인트를 정의하지만, 정점 팩토리는 정점 셰이더에 전달될 데이터를 정합니다. 셰이더는 불특정 한 변수 이름들 (FVertexFactoryInput처럼)을 사용해서 서로 다른 정점 팩토리마다 서로 다른 의미를 가지도록 합니다. UPrimitiveComponent/FPrimitiveSceneProxy는 장면에서 데이터를 가져와서 GPU에 특정한 데이터 레이아웃으로 올리기 위해 협력합니다.
셰이더 파이프라인에 대한 각주
언리얼은 "셰이더 파이프라인"이라는 개념을 가지고 있습니다. 이 개념은 파이프라인에서 여러 셰이더들 (vertex, pixel)을 함께 다뤄서 입력과 출력을 보고 최적화할 수 있도록 합니다. 이들은 엔진 내의 세 개 장소에서 사용되고 있는데: DepthRendering, MobileTranslucentRendering, 그리고 VelocityRendering. 제가 이 주제에 대해 광범위하게 다룰정도로 이해를 하진 못했지만, 만약 위 세 가지 시스템중 한 가지 시스템에서 작업을 하고 계시고 단계 사이에서 최적화돼버리는 시맨틱에 대해 문제를 겪고 있다면 IMPLEMENT_SHADERPIPELINE_TYPE_*을 살펴보세요.
다음 포스트
다음 포스트에선 드로잉 정책이 뭔지, 드로잉 정책 팩토리가 뭔지 알아보기 시작하고 언리얼이 실제로 어떻게 GPU에게 메시를 그리라고 말해주는지에 대해 알아볼 것입니다. 다음 포스트는 여기서 보실 수 있습니다!
'개인 공부' 카테고리의 다른 글
[역] 언리얼 엔진 4.22를 위한 메시 드로잉 파이프라인 변환 가이드 (0) | 2025.04.11 |
---|---|
[역] 언리얼 엔진 4 렌더링 파트 3: 드로잉 정책 (0) | 2025.04.10 |
[역] 언리얼 엔진 4 렌더링 파트 1: 소개 (0) | 2025.04.09 |
[번역] Resource State Tracking in D3D12 (0) | 2022.06.04 |
[번역] Implementing Dynamic Resources with Direct3D12 (0) | 2022.05.31 |