그래픽스

컴퓨터 그래픽스 - 우버 쉐이더 (Uber Shader)

오의현 2024. 10. 9. 16:15

쉐이더를 관리하는 기법 중 우버 쉐이더라는 것이 있다고 한다.

기본적으로 쉐이더는 여러개를 만들어 관리한다.

 

빛이 적용되는 것, 빛이 적용되지 않는 것, 알베도 텍스쳐를 사용하는 것, 노말맵을 사용하는 것 혹은 사용하지 않는 것 등 다양한 쉐이더를 만들어 필요에 따라 렌더러에 세팅하여 사용하는 것이 일반적인 쉐이더 관리 방식이다.

 

반면, 우버 쉐이더는 한 개의 쉐이더로 모든 렌더링을 처리한다.

(아예 단 1개만 있는 것은 아니고 주요 쉐이더를 1개만 사용한다는 느낌인 듯 하다.)  

 

그렇다면, 어떻게 1개의 쉐이더로 다양한 렌더링을 적용할 수 있을까?

상식적으로는 불가능하지만, 전처리기 구문을 활용하면 가능하다.

 

아래의 코드를 보자.

struct PS_Input
{
    float4 Pos : SV_POSITION;
    float4 NORMAL : NORMAL;
    float2 UV : TEXCOORD;
};

Texture2D DiffuseTex : register(t0);
Texture2D NormalTex : register(t1);

float4 PS_Main(PS_Input _Input) : SV_Target
{
    텍스쳐 샘플링
    노말맵을 사용하여 빛 계산
    최종 색상 계산
    
    return 최종 색상;
}
struct PS_Input
{
    float4 Pos : SV_POSITION;
    float4 NORMAL : NORMAL;
    float2 UV : TEXCOORD;
};

Texture2D DiffuseTex : register(t0);

float4 PS_Main(PS_Input _Input) : SV_Target
{
    텍스쳐 샘플링
    버텍스의 노말을 이용하여 빛 계산
    최종 색상 계산
    
    return 최종 색상;
}

 

두 코드는 각각 픽셀쉐이더의 HLSL코드이다. 위의 코드는 빛 계산에 노말맵을 사용하는 것이고, 아래의 코드는 노말맵을 사용하지 않고 버텍스의 노말값을 사용하는 코드이다.

 

일반적으론 이렇게 2개의 쉐이더 파일을 만들어 각각 컴파일한 뒤, 렌더러에 세팅하게 된다.

 

하지만, 우버 쉐이더를 사용하면 아래와 같이 처리할 수 있다.

struct PS_Input
{
    float4 Pos : SV_POSITION;
    float4 NORMAL : NORMAL;
    float2 UV : TEXCOORD;
};

Texture2D DiffuseTex : register(t0);

#ifdef USE_NORMAL_TEXTURE
Texture2D NormalTex : register(t1);
#endif

float4 PS_Main(PS_Input _Input) : SV_Target
{
    텍스쳐 샘플링
	float4 Normal = _Input;

#ifdef USE_NORMAL_TEXTURE
	Normal = 노말맵의 샘플링된 값;
#endif

	Normal을 이용해 빛 계산
    최종 색상 계산
    
    return 최종 색상;
}

이렇게 ifdef ~ #endif 전처리 구문을 사용하여 USE_NORMAL_TEXTURE가 #define으로 정의되어 있다면 Normal맵과 관련된 코드가 활성화되고 정의되어 있지 않다면 코드가 비활성화되어 버텍스의 노말값으로 연산하도록 하는 것이다.

 

이 방식을 이용하면 알베도, 러프니스, 하이트맵 등 다양한 텍스쳐의 사용 여부를 간단하게 설정할 수 있고 쉐이더 파일 하나로 다양한 렌더링 효과를 구현할 수 있게 된다.

 

그런데, 이렇게 쉐이더 파일 하나만 만들면 결국 하나의 쉐이더만 컴파일 되는 것이고, 그 쉐이더는 쉐이더 파일 상단에 작성한 #define 구문에 따라 옵션이 고정되는 것이 아닌가? 하는 의문이 생길 수도 있을 것이다.

 

이 쉐이더 파일은 그대로 컴파일하는 것이 아니라, 컴파일 직전에 수정되어서 컴파일된다.

어떻게? 파일 입출력을 이용하여 하는 것이다.

 

쉐이더 컴파일 전에 파일 입출력을 통해 파일의 코드를 문자열로 읽어온 뒤, 최상단에 필요한 옵션에 대한 문자열을 추가하고 그걸 이용해서 컴파일하고, 또 다시 상단의 define을 수정하여 그걸로 컴파일을 하고 쭉쭉하여서 하나의 쉐이더 파일을 사용해 여러 쉐이더를 컴파일하는 것이다.

 

즉, 원본의 쉐이더 파일에 직접 #define ~~를 작성하는 것이 아니라 로딩타임에 #define 구문을 추가하여 컴파일 하는 것이다. 

 

그런데 또 이런 의문이 들 수도 있다. "그냥 if~else if~ else 쓰고 상수버퍼로 옵션을 켜고끄면 안되나?"

물론 안되는 건 아니다. 다만, 안좋으니까 안하는 것이다.

 

쉐이더 파일에서 if~else구문은 정말 조심히 사용해야 한다. GPU의 레지스터를 통해 병렬연산을 할 때, 효율적으로 연산을 처리하려면 모든 레지스터가 실행하는 작업이 동일해야 한다.

 

아래의 코드를 보자.

if(UseNormalMap == true)
{
    //노말맵사용
}

if(UseLight == true)
{
    //빛 적용
}

if(UseHeightMap == true)
{
    //하이트맵 사용
}

이런 코드가 있다고 했을 때, UseNormal 도 false고 UseLight도 false고 UseHeightMap도 false라고 해보자.

우리가 기대하는 것은 위의 if문 내부의 코드가 모두 무시되는 것을 기대할 것이다.

 

하지만, GPU에선 저 내부의 코드까지도 모두 실행한 다음 결과값을 버려버리는 방식으로 연산이 된다.

왜냐하면, 위에서 말했듯이 GPU의 레지스터는 모두 동일한 작업을 수행해야 하기 때문이다.

 

레지스터마다 다른 분기에 진입하면 효율적인 병렬연산이 불가능해지기 때문에, GPU는 모든 분기의 코드를 다 실행한다음 필요한 결과만 취하는 방식으로 작동하게 된다.

 

즉, if~else 구문을 쉐이더에서 사용하는 것은 상당한 연산의 낭비로 이어질 수 있다는 것이다.

그 뿐만이 아니라, 모든 옵션을 상수버퍼로 사용하게 되면 그만큼 버퍼 메모리가 사용된다는 것이다.

 

즉, ifdef를 사용하는 것에 비해 단점은 많아도 장점이라고 할 것이 딱히 없는 것이다.

 

우버쉐이더는 이렇게 하나의 쉐이더 파일을 다양하게 활용할 수 있기 때문에, 하나의 파일만 유지보수하면 된다는 장점이 있지만, 단점은 유지보수를 하는 것이 다소 어려울 수 있다는 것이다. 하나의 쉐이더에 모든 코드가 집약되어 있는 경우엔 코드 길이가 꽤나 길텐데 거기서 오류를 찾고 고치는 것은 상대적으로 어려운 일이 될 것이다. 또한, 다른 코드에 영향을 주지 않게 수정하는 것도 옵션이 많아질수록 어려워 질 수 있을 것이다. 

 

그렇다면 이 우버 쉐이더는 어떤 식으로 엔진에 적용해야 할까? 본인도 아직 우버쉐이더를 사용해보진 않았지만, 엔진에 적용하는 방법을 생각해보자면 이런식으로 할 것 같다.

(아래 글은 참고로 본인 뇌피셜 및 망상이다. 안써봐서 실제로 저렇게 하면 문제없이 되는지는 잘 모른다.)

 

렌더러가 3개가 있고 1번 렌더러는 Albedo, Normal Map을 사용하고, 2번 렌더러는 Albedo만 사용하고, 3번 렌더러는 둘 다 안쓴다고 해보자. 먼저, 에디터를 통해 각 렌더러 별로 옵션을 선택한 뒤에 이를 직렬화하여 파일에 저장한다.

 

파일에서 각 옵션 조합을 읽어온다. (겹치는 것은 제거해야 한다. 아니면 아예 파일에 저장할 때 겹치치 않도록 하거나)

그리고, 해당 조합을 비트 플래그를 사용해 unsigned int에 저장한다. 

 

(예를 들어, 맨 끝의 4자리 비트가 0000이라고 했을 때, 가장 마지막을 Normal사용여부, 그 앞을 Albedo사용 여부라고 한다면 Normal과 Albedo를 모두 사용하는 것은 0011이 되고, Normal만 사용하는 것은 0001이 되고, 둘 다 사용하지 않는 것은 0000이 되는 것이다.)

 

그리고, 이 비트플래그를 사용해 쉐이더 파일의 앞에 문자열을 추가한다.

예를 들어, unsigned int의 가장 마지막이 1이라면 #define USE_NORMALMAP 를 추가하고, 그 앞의 비트가 1이라면 #define USE_ALBEDO를 추가하는 것이다.

 

이런 방식으로 조합에 따라 여러 쉐이더를 컴파일하게 되면, 하나의 쉐이더 파일을 가지고 렌더러가 사용하는 옵션 조합에 따라 다양하게 컴파일 할 수 있게 되는 것이다.

 

굳이 비트플래그를 사용하는 이유라면, 여러 옵션 조합을 하나의 unsigned int로 관리할 수 있기 때문이다.

이 값을 map의 key로 사용하면 동일한 옵션 조합의 쉐이더가 중복 컴파일 되는 것을 방지할 수도 있고, key의 이름 작명을 고민할 필요도 없고, 문자열보다 메모리도 덜먹고 등등...

 

아무튼 우버 쉐이더라는 것에 대해 알아보았다. 언리얼 엔진과 유니티도 내부적으로 우버쉐이더를 활용한다고 하니 알아두면 요긴한 지식이 되지 않을까 싶다.