이 게시글에선 실제로 노말맵을 적용하는 수학적 연산은 설명하지 않을 것이다.

두 노말맵의 종류와 장단점에 대해선만 설명할 것..

 

우리는 3D 오브젝트의 표면 색상을 자연스럽게 결정짓기 위해 표면의 노말(법선)을 사용해 빛을 계산하여 색상을 결정짓곤 한다.

 

일반적으로 이 노말 벡터의 정보는 각 버텍스(정점)에 담겨져 있으며 버텍스 쉐이더의 Input으로 받아서 사용할 수 있다.

하지만, 버텍스에 저장된 노말 벡터를 사용하는 것은 다소 한계가 있다.

이러한 모델을 생각해 보자. 벽돌 사이 시멘트 부분은 움푹 파여있으므로 이 부분에 대해선 노말벡터가 벽돌의 평면과 다르게 적용되어야 한다. 이러한 노말 벡터의 값을 모든 정점이 보유하게 하려면 해당 모델의 정점 개수는 매우 많아질 것이고 이는 메모리 사용량과 쉐이더 연산량의 증가로 이어진다. 즉 최적화 측면에서 매우 손실을 보게 된다.

 

이러한 개념에서 시작된 것이 노말맵이다. 먼저, 위의 모델을 표현하기 위해 4각형 하나를 만들고, 그 위에 평면의 벽돌 색상의 디퓨즈(알베도) 텍스쳐를 입히는 것이다. 그리고 또 하나의 텍스쳐에는 의도에 맞게 노말 벡터를 저장하여, 버텍스에 저장된 노말 값이 아닌 해당 텍스쳐에 저장된 노말값을 사용하여 빛을 연산하는 것이다.

 

이렇게 되면, 정점별로 노말 벡터를 저장하기 위해 매우 많은 정점을 만들고 데이터를 보관할 필요 없이 하나의 텍스쳐만을 추가로 활용하여 음영을 디테일하게 연산할 수 있게 되는 것이다. (물체를 실제로 울퉁불퉁하게 만드는 것이 아니라, 실제로는 평면이지만 울퉁불퉁한 물체인 것처럼 노말 벡터를 저장하여 빛을 연산하여 속이는 것이다.)

 

그리고, 그 노말 벡터가 저장된 텍스쳐를 노말 맵(Normal Map)이라고 한다.

이러한 노말 맵에는 두 가지의 종류가 있다. 로컬 스페이스 노말 맵과 탄젠트 스페이스 노말 맵이다.

 

로컬 스페이스 노말 맵

로컬 스페이스 노말 맵이란, 3D 모델의 방향(회전이 적용되지 않은 상태)를 기준으로 노말을 계산하여 저장하는 것이다.

 

이렇게 구형의 물체가 있다고 가정해보자. 회색 네모가 칠해진 최상단의 버텍스의 UV좌표가 (0, 0)이라고 가정해보자.

하늘색 화살표는 해당 정점의 노말 벡터이다.

 

이 때, 물체가 회전하여 아래와 같이 된다면?

이 때, 노말 벡터는 아래 그림처럼 향하는 것이 우리가 의도한 방향일 것이다.

하지만 노말맵에서 매핑하여 사용하게 되면 아래 그림과 같이 되어있다.

노말 맵은 물체가 회전되지 않은 최초의 형태를 기준으로 값이 저장되어 있으므로, 노말맵의 값을 그대로 사용하면 이렇게 노말이 제대로 적용되지 않는다. 그러므로 물체의 회전 행렬을 노말 벡터에도 곱해주어 노말 벡터에 매번 회전을 적용해주어야 정상적으로 적용된다.

 

로컬 스페이스의 노말 맵은 이처럼 물체의 회전에 영향을 받지 않는 상태로 저장되어 있으므로 매번 회전을 적용하여 벡터의 방향을 갱신해야 한다.

 

탄젠트 스페이스 노말맵

탄젠트 스페이스 노말 맵은 버텍스의 탄젠트, 바이탄젠트, 노말 값을 축으로 사용하는 탄젠트 공간에서 저장된 노말 벡터를 사용하는 노말 맵이다. 이게 무슨말이냐면...

 

하나의 정점에는 노말 벡터가 존재하고, 그 노말 벡터에 수직인 탄젠트가 존재한다. 여기서 탄젠트란 해당 정점의 접선이라고 할 수 있다. 그리고 탄젠트 벡터와 노말 벡터에 대해 수직인 또 하나의 벡터(바이 탄젠트)도 존재할 것이다.

 

이 3개의 벡터를 X,Y,Z 축으로 활용해 T(탄젠트), B(바이 탄젠트), N(노말) 축을 만들 수 있고 이 축에 대해 노말 값을 저장하는 것이 탄젠트 스페이스 노말 맵이다.

 

그런데 왜 이렇게 복잡하게 노말 값을 저장하는 걸까?

 

로컬 스페이스 노말맵과 탄젠트스페이스 노말맵의 장단점

일반적으로 프로그래밍에서 어떠한 기법의 장점을 말한다면 항상 3가지 중 하나로 답이 나온다.

빠르거나, 메모리를 적게 사용하거나, 편하거나.

 

이 3개를 기준으로 두 노말맵을 비교해보겠다.

1. 누가 더 빠른가?

로컬 스페이스 노말맵은 매번 픽셀 쉐이더에서 회전행렬을 곱해주어야 한다.

하지만, 탄젠트 스페이스 노말맵은 회전행렬을 버텍스 쉐이더에서 곱해도 된다.

 

왜냐하면, 탄젠트 스페이스에서 정의된 노말 벡터의 경우, 노말 벡터에 회전을 적용하는 것이 아니라 정점의 탄젠트, 바이 탄젠트, 노말에 회전을 적용해야 하기 때문이다. 이 정보는 정점 단위로 주어지므로 버텍스 쉐이더에서 해결할 수 있다.

 

즉 회전 연산 자체는 탄젠트 스페이스가 일반적으로 더 적게 수행되는 편이다.

 

하지만, 빛을 적용하기 위해선 광원과 노말벡터의 좌표계를 맞춰주어야 한다. 광원을 탄젠트 스페이스로 옮기든가, 노말 벡터를 월드 스페이스로 옮기든가. 광원을 탄젠트 스페이스로 옮기는 것은 버텍스 쉐이더에서 수행할 수 있으므로 광원을 탄젠트 스페이스로 옮겨서 빛을 계산하는 것이 일반적으로 더 효율적일 것이다.

 

하지만, 로컬 스페이스 노말맵의 경우엔 이런 연산을 하지 않아도 된다. (이미 광원과 좌표계가 일치하므로)

그렇기 때문에 회전하지 않는 물체에 대해서 로컬 스페이스 노말맵은 회전도 좌표변환도 수행하지 않아도 되기 때문에 연산량이 상대적으로 매우 적지만, 탄젠트 스페이스 노말맵은 항상 좌표계를 맞춰주는 연산을 해야하기 때문에 회전하지 않는 물체에 대해서는 더 많은 연산이 수행될 것이다.

 

그러므로 일반적으로 생각했을 때, 회전하는 동적인 물체에 대해선 탄젠트 스페이스 노말 맵이 더 성능에 유리하지만 회전하지 않는 정적인 물체에 대해선 로컬 스페이스 노말 맵이 더 성능에 유리할 것이다.

2. 누가 더 메모리를 적게 사용하는가?

노말 맵에 저장된 노말 벡터는 일반적으로 정규화되어 저장된다. 정규화되었다는 뜻은 X^2 + Y^2 + Z^2의 값이 1이라는 뜻이다. 이 때, X, Y 를 알면 Z를 얼추 구할 수 있다. Z^2 = 1 - X^2 - Y^2 이기 때문이다.

 

하지만, X, Y를 알더라도 z를 정확히 구할 수는 없다. 왜냐하면 양수와 음수 두 가지가 존재하기 때문이다. 어느 것이 실제 값인지 유추할 수 없으므로 우리는 x,y,z를 모두 정확히 알아야만 한다.

 

하지만, 탄젠트 스페이스의 경우 조금 다르다. 탄젠트 스페이스는 정점을 기준으로 정의된 공간이다. 이 탄젠트 스페이스에서 정점의 노말은 항상 양의 방향을 향한다. (당연하지만 법선은 평면의 표면이 향하는 방향이다. 이 방향은 항상 양의 방향일 수 밖에 없다.)

 

그러므로 탄젠트 스페이스에서 정의된 노말 벡터는 x, y만 알더라도 z를 유추할 수 있게 된다. 그러므로 노말 맵에도 R,G,B 채널 모두 사용할 필요 없이 R,G 채널만 사용하여 값을 저장할 수 있게 된다. 이로 인해 탄젠트 스페이스 노말맵은 B, A 채널을 다른 목적(러프니스, 메탈릭 등)을 위해 사용할 수 있게 되고, 이로 인해 메모리를 절약할 수 있게 된다.

 

3. 누가 더 사용하기 편한가?

이건 사실 둘 다 또이또이 인 듯 하다. 둘 다 사용하기 어려운 것도 아니고, 누가 더 편하고 말고 할 건 크게 없는 듯 하다. 굳이 따지자면 탄젠트 스페이스의 경우 좌표계를 고려해야 하기 때문에 조금 더 복잡할 수는 있다.

 

결론

회전하지 않는 물체에 대해서 로컬 스페이스 노말맵을 사용하게 되면, 메모리를 조금 더 사용하는 대신 더 빠르게 연산을 수행할 수 있다.

 

회전하는 물체에 대해선 탄젠트 스페이스 노말맵이 성능, 메모리 두 측면에서 모두 유리하다.

그래픽스에서 오브젝트에 명암을 부여하기 위해 빛을 연산하는 방식은 크게 3가지가 있다.

바로 포워드 렌더링, 디퍼드 렌더링, 포워드+ 렌더링이다.

(참고로 이 셋은 레스터화 렌더링에서 사용하는 것이고 레이트레이싱은 아예 다른 방식으로 렌더링을 수행한다.)

 

포워드 렌더링

포워드 렌더링이란, 오브젝트를 렌더링할 때 빛을 바로바로 적용하는 방식이다. 하나의 오브젝트를 렌더링할 때, 레벨에 30개의 광원이 있다면 모든 광원에 대해 빛을 계산하여 색상을 결정짓게 된다. 가장 단순하면서 기본적인 렌더링 방식이다. 이 렌더링 방식은 구현이 단순하지만, 단점이 하나 있다. 바로 모든 오브젝트의 빛 계산을 수행한다는 것이다.

 

예를 들어, 이런 상황을 보자. 뒤쪽에 사람이 있는데 앞에 있는 건물에 의해 가려진다고 해보자. 이 경우 건물에 대해서도 빛 연산을 수행하고 사람에 대해서도 빛 연산을 수행하지만 실제로 사람의 색상은 모두 버려지게 된다. (깊이 테스트에 의해 건물의 색상만 남는다.)

 

그러므로, 불필요한 빛 연산이 수행된 것이다. 완전히 가려지는 경우라면 오클루전 컬링 등으로 아예 렌더링에서 제외할 수 있겠지만 반만 걸쳐있는 경우라면? 이 경우 사람의 반쪽에 해당하는 픽셀에 대해선 불필요한 연산이 들어갈 수 밖에 없다.

 

이처럼 연산을 했음에도 실제로 렌더링이 되지 않는 경우가 발생하기 문제를 개선하기 위해 디퍼드 렌더링이 탄생하였다.

 

디퍼드 렌더링

디퍼드 렌더링이란 이름 그대로 렌더링을 늦추는 것이다. 오브젝트의 드로우 콜이 발생하면 바로 빛을 적용하고 렌더타겟에 그리는 것이 아니라, 화면에 실제로 그려지는 부분만 추출하여 빛을 적용하는 것이다. 즉, 가려지는 부분에서 연산의 낭비가 발생하지 않는 것이다.

 

그렇다면 어떻게 이런 것을 할까?

출처 : https://www.3dgep.com/forward-plus/

 

이렇게 멀티 렌더타겟 기능을 활용하는 것이다. 빛 연산을 바로바로 적용하는 것이 아니라 각 오브젝트의 렌더링을 수행할 때 DiffuseColor, Normal, Specular, Depth를 각각의 타겟에 기록한다. 

 

모든 오브젝트가 다 기록되면, 마지막에 4개의 타겟을 이용해 하나로 합치는 것이다. DiffuseColor에 Specular, Normal, Depth를 활용해 빛을 적용하여 최종 렌더타겟에 출력하는 방식으로 말이다.

 

포워드 렌더링의 경우 드로우 콜이 발생한 오브젝트가 그려지는 픽셀 개수의 합이 Sum(P)이고 N개이고 광원이 M개라면 Sum(P) * N * M 만큼의 빛 연산이 발생한다.

 

하지만, 디퍼드 렌더링의 경우 오브젝트가 몇개든 화면 픽셀 개수 * M 만큼의 빛 연산만 발생하게 된다. 오브젝트 개수와 관계 없이 빛 연산이 수행되는 것이다. 

 

이러한 특징으로 인해, 디퍼드 렌더링은 한 화면에 렌더링되는 오브젝트가 많을수록 포워드 렌더링에 비해 현저히 빠른 렌더링 속도를 보여준다. 하지만, 반대로 말하면 렌더링되는 오브젝트가 적다면 포워드 렌더링이 더 빠를 확률이 높다.

 

또한, 디퍼드 렌더링은 한 번의 렌더링에서 여러개의 렌더타겟을 사용하는 만큼 GPU와 CPU 사이의 높은 대역폭을 필요로 한다. 현대의 하드웨어 성능에서 사실 문제되는 부분은 아니지만 이게 모바일 환경에서는 조금 얘기가 다르다. 본인도 하드웨어를 잘 아는 건 아니지만 높은 대역폭을 요구할수록 발열이 심해지고 이로 인해 전력 효율을 크게 감소시킨다고 한다. 그래서 모바일 환경에선 디퍼드 렌더링을 잘 사용하지 않는다고 한다.

 

이 외에도 디퍼드 렌더링은 치명적인 문제가 하나 있는데, 반투명 물체를 처리하는 것이 매우 까다롭다는 것이다. 포워드 렌더링은 반투명 물체를 렌더링할 때 기존의 색상과 블렌드해주면 그만이지만 디퍼드 렌더링은 깊이만을 사용해 물체를 기록하기 때문에 반투명한 물체가 제대로 기록되지 않는다. 

 

이러한 문제를 해결하기 위해 본인은 과거 프로젝트에서 렌더링 패스를 활용했었다. 불투명한 물체의 포워드 렌더링을 먼저 수행하고, 불투명한 물체의 디퍼드 렌더링을 수행하고, 마지막에 반투명 물체를 렌더링하는 것이다. (디퍼드 렌더링 자체에서 반투명을 해결하는 것이 매우 힘들다고 판단하여, 아예 반투명 물체는 별도로 렌더링한 것이다.)

 

반투명 물체를 먼저 렌더링하게 되면 디퍼드 렌더링 과정에서 그 뒤에 그려져야 하는 대상을 클리핑하기 때문에 반투명 물체를 가장 마지막에 렌더링해주었다.

 

포워드+ 렌더링

포워드 렌더링과 디퍼드 렌더링을 보면 둘 다 명확한 장단점이 있다.

 

포워드 렌더링은 오브젝트와 광원의 수가 많아질수록 빛 연산의 부담이 엄청 커지지만, 반투명 물체 등을 정확하게 렌더링할 수 있다. 반면 디퍼드 렌더링은 빛 연산이 오브젝트의 개수에 독립적으로 수행되기 때문에 상황에 따라 빛 연산을 크게 줄일 수 있지만 반투명 물체를 제대로 렌더링하지 못하는 등의 문제가 발생할 수 있다.

 

그렇다면, 포워드 렌더링에서 빛 연산을 최대한 줄일 수 있다면, 가장 좋은 결과가 아닌가? 하는 아이디어에서 나온 것이 포워드+ 렌더링이다. 

 

먼저, 포워드 렌더링을 수행할 때 광원을 무식하게 모두 렌더링하는 것이 아니라 라이트 컬링을 한 뒤에 렌더링을 수행한다. 렌더링 전에 오브젝트에 실제로 적용이 될 광원만 걸러내 쉐이더에 전달하는 것이다. 이를 통해 불필요한 빛 연산을 줄일 수 있게 된다. 하지만, 하나의 오브젝트에서도 위쪽에는 광원이 20개가 적용되는데 아래쪽에는 10개만 적용되는 등의 상황을 생각해보면 불필요한 빛연산이 모두 사라지는 것은 아니다.

 

이를 위해 화면을 여러개의 타일로 쪼개고, 각 타일별로 적용될 광원을 걸러내는 것이 포워드+ 렌더링이다.

출처 : https://www.3dgep.com/forward-plus/

 위의 그림처럼 화면을 타일로 쪼갠 뒤, 각 타일별로 적용되는 광원을 추출하고 해당 광원에 대해서만 연산을 수행하는 것이다. 이를 통해 오브젝트에서 수행하는 빛 연산을 최소한으로 줄일 수 있게 되는 것이다. 

 

이 방식도 물론 단점은 존재하는데, 타일을 너무 크게 쪼개면 불필요한 빛 연산이 포함되는 수가 많아지고, 타일을 너무 작게 쪼개면 메모리 사용량이나 타일 처리 과정에서 발생하는 연산 횟수가 증가한다는 것이다.

 

즉, 너무 잘게 쪼개면 감소한 빛 연산보다 오히려 타일 관리 부분에서 더 큰 오버헤드가 발생할 수도 있다는 것이다. 그래서 적절한 타일 크기를 구성하는 것이 중요하다고 한다.

 

모바일 게임에서 높은 퀄리티의 그래픽 품질을 구현하려면 연산 부담을 최소화 해야 하는데, 디퍼드 렌더링의 경우 빛 연산이 감소하더라도 높은 대역폭 요구치로 인한 문제가 더 크게 발생할 수 있어 포워드 렌더링이 거의 강제되는 상황이었는데 Forward+ 렌더링을 잘 활용하면 훨씬 높은 수준의 최적화를 달성할 수 있어서 모바일 게임에서 종종 사용된다고 한다.

 

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

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

 

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

 

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

(아예 단 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의 이름 작명을 고민할 필요도 없고, 문자열보다 메모리도 덜먹고 등등...

 

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

알파 테스팅

알파 테스팅이란, 물체를 투명, 불투명으로만 처리하는 방식이다.

알파 값을 0 혹은 1로만 사용하며, 투명한 부분은 아예 클리핑하는 기법이다.

 

반투명 처리를 하지 않기 때문에, 처리 속도가 매우 빠른 편이라는 장점이 있지만 반투명을 처리하지 않는 만큼 그래픽 표현에 제약이 있는 방식이다.

 

또한, 오브젝트를 불투명으로만 렌더링하기 때문에 뒤에 가려지는 물체에 대한 렌더링을 할 필요가 없으며 깊이 테스트 또한 정확하게 실행할 수 있게 된다.

 

알파 블렌딩

반투명을 허용하며 겹쳐있는 물체들의 색을 보간하여 최종 색상을 결정하는 방식이다.

반투명을 처리하는 만큼, 하나의 픽셀에 대해 여러 번의 렌더링이 실행될 수 있다.

 

알파 테스팅에 비해 다양한 그래픽 표현을 할 수 있게 되지만, 그만큼 처리 속도가 느리다.

또한, 렌더링 순서에 따라 의도하지 않은 클리핑이 발생하는 경우도 있다.

 

이미지 출처 : https://rito15.github.io/posts/unity-transparent-stencil/

위의 그림을 보자.

 

앞에 먼저 그려진 풀의 경우, 알파가 0인 부분도 클리핑하지 않고 렌더링을 실행하게 된다. 그 이후 뒤에 있는 풀을 그리게 되면 앞의 풀보다 뒤에 있는 부분이 깊이 테스트에서 걸러지기 때문에, 뒤의 풀이 그려져야 함에도 불구하고 일부분이 그려지지 않는 것을 확인할 수 있다.

 

알파 블렌딩은 이처럼 그려지는 순서나 위치에 따라 의도하지 않은 클리핑이 발생할 수 있다.

 

알파 소팅

위의 문제를 해결하기 위해선, 간단한 방법이 있다.

뒤에 있는 물체부터 그리는 것이다.

 

위의 경우, 붉은색 풀의 일부가 클리핑되는 이유는 초록색 풀이 그려진 부분에서 투명한 곳의 깊이보다 더 깊이가 크기 때문에, 깊이 테스트에서 걸러졌기 때문이다. 

 

그렇다면, 가장 뒤의 오브젝트부터 순서대로 앞의 오브젝트를 렌더링하게 되면 기존의 깊이보다 깊이가 더 작은 경우에만 렌더링되기 때문에 위의 그림처럼 일부가 가려지는 경우가 발생하지 않을 것이다.

 

하지만, 알파 소팅을 사용한다고 하더라도 모든 상황에서 문제가 해결되는 것은 아니다.

알파 소팅은 기본적으로 CPU에서 카메라와 물체의 거리를 기준으로 오브젝트를 정렬한 뒤, 렌더링 순서를 정하는 방식이다. 즉, 거리가 더 먼 대상을 먼저 렌더링 한다는 것이다.

 

그렇다면, 아래의 그림을 보자.

위의 경우에 초록색 물체가 주황색 물체보다 더 앞에 있는 것을 볼 수 있다.

하지만, 카메라와의 거리를 기준으로 판단한다면 초록색 물체가 더 멀리 있다고 볼 수 있다.

이로 인해, 실제로는 주황색 물체가 먼저 그려져야 함에도 불구하고 초록색 물체가 먼저 그려지는 상황이 발생할 수 있다.

출처 : https://darkcatgame.tistory.com/31

 

이러한 문제를 해결할 수 있는 가장 간단한 방법은 깊이 버퍼를 사용하지 않는 것이다.

새로 그려지는 물체가 항상 앞에 그려지도록 한다면, 위와 같은 문제 없이 간단하게 알파 소팅의 문제점을 해결할 수 있다.

하지만, 깊이 버퍼를 사용하지 않으면 또 다른 문제가 발생하게 된다.

출처 : https://darkcatgame.tistory.com/39

 

위와 같이, 물체의 내부가 비치는 상황이 발생할 수 있다. 깊이 테스트를 하지 않고 렌더링하기 때문에 반투명한 모든 오브젝트가 섞여서 렌더링되는 것이다.

 

위와 같이 반투명한 물체들이 겹쳐서 렌더링 되는 경우에 내부가 비치지 않도록 하고 싶다면 2Pass 렌더링을 해야한다.

먼저, 1차적으로 오브젝트를 불투명 상태로 렌더링을 하며 렌더타겟에 깊이를 기록한다.

 

이후, 해당 깊이값을 이용하여 반투명으로 다시 렌더링하는 것이다. 1차적으로 저장했던 깊이 값보다 더 큰 깊이 값을 가지고 있다면 렌더링하지 않도록 하는 것이다. 이를 통해, 내부가 비치지 않도록 해결할 수 있다.

 

이 방식은 2번의 Draw Call이 발생하므로, 필요한 상황에만 적절하게 사용하는 것이 좋다. (오버헤드가 발생할 수 있음)

컴퓨터 그래픽스에서 가장 근본적으로 추구하는 것은 최종적으로 렌더링되는 장면의 품질이다.

 

어떻게 하면 우리가 의도한 것을 그대로 화면에 표현할 수 있는지 혹은 얼마나 더 자연스럽고 사실적으로 표현할 수 있는가에 대한 탐구가 컴퓨터 그래픽스의 근본적 목적이다.

 

하지만, 우리의 하드웨어는 한계가 있다. 가상의 공간에 현실과 유사하게 렌더링 할 수 있는 기술은 이미 수십년 전부터 마련이 되어 있었지만, 현재 까지도 하드웨어는 그 연산을 모두 감당할 수 없는 상태이다.

 

이러한 문제로 인해, 그래픽스 분야에선 다양한 최적화 기법이 연구되고 있지만 당연하게도 최적화 기법을 적용하게 되면 어느정도의 품질 손상을 감수해야만 한다. 

 

가능한 품질의 손상을 적게 하면서 높은 수준의 최적화를 이룰 수 있는 방법으로 고안된 최적화 기법 중 가장 대표적인 것이 LOD이다.

 

LOD (Level Of Detail)

LOD란, 이름 그대로 디테일의 정도를 조절하는 기능이다. 

 

그래픽스에서 최적화를 달성하는 방법은 크게 두 가지가 있다. 

하나는 드로우 콜을 줄이는 것이고, 하나는 렌더링되는 폴리곤을 줄이는 것이다.

 

LOD는 후자에 속한다. 렌더링이 되는 매쉬의 폴리곤을 줄이는 것이다.

그런데, 폴리곤을 무작정 줄이면 오브젝트의 모양이 상당히 부자연스럽게 보일 것이다.

 

아래 사진을 보자.

 

언리언 엔진의 기능을 사용하여, LOD를 적용한 것이다.

왼쪽은 원본 그대로이고, 오른쪽은 폴리곤의 수를 줄인 채로 렌더링한 것이다.

상당히 부자연스럽고 뾰족하게 렌더링된 것을 볼 수 있다.

이렇게 렌더링하면, 최적화 부분에서야 당연히 이점이 있겠지만 렌더링 퀄리티가 너무 처참하게 낮아진다.

 

그럼에도 불구하고 LOD를 쓰는 이유는 뭘까?

아래 사진도 한 번 보자.

이번엔, 조금 멀리서 오브젝트를 렌더링하였다. 가까이 봤을 때랑 비교했을 때, 차이가 느껴지지 않는다.

주변에 배경이 깔리고 여러가지 효과가 적용되면 사실상 차이를 느끼지 못할 것이다.

 

폴리곤의 개수를 줄이면, 가까이서 봤을 땐 매쉬의 모양이 상당히 부자연스럽게 렌더링된다.

하지만, 멀리서 본다면 제대로 렌더링 했을 때와 그렇게 큰 차이를 느낄 수가 없다.

그렇다면, 멀리서 볼 땐 폴리곤의 개수를 줄이는 것이 좋은게 아닐까?

 

LOD는 이런 개념에서 시작된 것이다. 오브젝트가 멀어지거나 작아질수록 디테일하게 렌더링되지 못한다.

그러므로, 폴리곤을 줄이거나 텍스쳐의 해상도를 낮춰도 거의 티가 나지 않는다.

 

이처럼, 거리 등을 기준으로 폴리곤의 개수를 유동적으로 줄였다 늘렸다 하는 기술이 LOD이다.

 

폴리곤의 개수가 10만개인 매쉬를 화면에 렌더링한다고 해보자.

그런데, 오브젝트가 아주 멀리있어서 거의 점처럼 보이는 상황이라면, 폴리곤을 무식하게 2~3개로 줄여버려도 큰 차이를 느끼지 못할 것이다. LOD를 사용하면, 10만개의 폴리곤을 2~3개로 만들어버릴 수도 있는 것이다. (실제로는 이렇게 극단적으로 줄이는 경우는 거의 없을 것이다.)

 

거리 기반 LOD, 면적 기반 LOD

LOD는 어떤 기준에 따라 폴리곤을 줄이고 늘리는 기법이다.

그 기준이 되는 것은 보통 거리와 면적이 있다.

 

거리란, 카메라의 위치를 기준으로 물체가 얼마나 멀리있느냐를 기준으로 하는 것이다.

오브젝트가 충분히 멀리있다면 폴리곤의 수를 기존보다 줄이고 충분히 가깝다면 원래의 폴리곤으로 렌더링을 하는 것이다. 

 

거리 기반 LOD는 면적 기반에 비해 연산이 상대적으로 가볍지만, 한 가지 문제가 있다.

거리로 봤을 때는 멀리 있지만, 실제로는 화면에 크게 렌더링되는 경우가 있다.

 

뒤 쪽의 황금 나무를 보자.

 

실제로는 카메라와 거리가 상당히 먼 곳에 위치하고 있지만, 그 크기가 매우 커서 화면에 아주 크게 렌더링되고 있다.

LOD는 어쨋든, 화면에서 잘 눈에 안띄어서 폴리곤의 수를 줄여도 시각적 경험의 차이가 적도록 하는 것이 목적이다.

그런데, 저렇게 크게 렌더링되고 있는 대상의 폴리곤을 무작정 줄여버리면 부자연스럽게 보일 수도 있을 것이다.

 

반면, 거리는 가까이 있지만 크기가 작아 디테일하게 표현할 필요가 없는 대상을 디테일하게 렌더링하는 경우도 있을 수 있다.

 

이 때문에, 거리 기반 LOD의 경우 상황에 따라 렌더링 품질이 감소하거나, LOD를 제대로 적용하지 못해 최적화를 달성하지 못하는 문제가 발생할 수 있다.

 

면적 기반 LOD는 물체가 화면에서 얼마나 차지하는 가를 기준으로 LOD를 적용한다. 화면에서 실제로 렌더링되고 있는 크기를 기준으로 폴리곤의 수를 조절하기 때문에, 거리 기반 LOD에 비해 렌더링 품질을 더 보존할 수 있다는 장점이 있다.

 

다만, 면적을 계산하는 과정을 CPU에서 진행해야 하기 때문에 CPU에 부하를 줄 수 있다는 단점이 있다.

 

거리 기반 LOD 면적 기반 LOD
거리를 기준으로 LOD 단계를 조절한다. 투영 면적을 기준으로 LOD 단계를 조절한다. 
LOD의 효율이 상대적으로 낮다. LOD의 효율이 상대적으로 높다.
CPU에 부하를 주는 정도가 상대적으로 적다. CPU에 부하를 주는 정도가 상대적으로 높다.

 

*LOD 단계 : 폴리곤의 수를 얼마나 줄일지 단계를 나누어 LOD를 적용한다.

*LOD 효율 : 렌더링 품질을 얼마나 보존할 수 있는가

 

Static LOD, Dynamic LOD

LOD란, 특정 기준에 따라 렌더링 되는 폴리곤의 수를 조절하는 것이라고 하였다.

폴리곤의 수를 조절하는 방법은 크게 두 가지로 분류된다.

 

static LOD는 디자이너가 매쉬를 만들 때, LOD단계에 따라 여러 개의 매쉬를 만들어 놓는 것이다.

하나의 파일(Fbx, Obj 등등)에 LOD단계에 따라 달라지는 버텍스의 정보가 미리 저장되어 있기 때문에 빠르게 런타임에 LOD단계를 조절할 수 있다. 다만, 미리 만들어 놓는다는 것은 그만큼 메모리 사용량이 많아진다는 뜻이다.

static LOD는 런타임 성능에 영향을 크게 미치지 않지만, 메모리를 많이 사용하게 되며 로딩 시간이 길어질 수 있다.

 

dynamic LOD는 버텍스의 수를 CPU연산을 통해 런타임에 조절하는 것이다. dynamic LOD는 LOD 단계에 따른 버텍스의 수를 미리 저장해놓지 않기 때문에, 메모리 사용량이 적지만 런타임 성능에 영향을 줄 수 있다는 단점이 있다.

 

dynamic LOD는 한 가지 장점이 더 있는데, static LOD는 LOD단계에 따른 폴리곤의 수가 미리 정해져 있기 때문에 LOD단계가 바뀔 때, 이전 단계와의 차이에 따라 유저가 급격한 변화를 느낄 수가 있다. 반면, dynamic LOD는 유동적으로 폴리곤의 수를 조절할 수 있기 때문에 상대적으로 변화를 느끼는 정도가 적다는 장점이 있다.

 

Popping

Popping란, LOD 단계가 변할 때 유저가 급격한 차이를 느끼는 현상이다. 만약, LOD가 변하는 특정 거리 근처에서 계속 왔다갔다 하게 되면 오브젝트가 계속 변하는 것을 확인할 수 있을 것이다.

 

위의 영상은 유튜브에서 발견한 LOD Popping 영상이다. 동영상을 보면, 오브젝트가 거리에 따라 변화하는 것을 볼 수 있는데, 이러한 문제를 Popping이라고 한다.

 

이를 해결하기 위해 LOD단계간의 폴리곤 개수나 위치 등을 보간하면서 자연스럽게 보이도록 하는 연산을 활용하기도 한다고 한다.

컴퓨터 렌더링 최적화에 관한 정보를 찾으면, '드로우 콜'이라는 용어를 시도 때도 없이 만나게 될 것이다.

드로우 콜이라는 것은 CPU 병목의 주 원인으로 꼽히며, 게임 최적화에서 빼놓을 수 없는 존재이다.

 

드로우 콜에 대해 알아보자.

 

드로우 콜

먼저, 드로우 콜이라는 것은 간단하게 말하자면, CPU가 GPU에게 렌더링을 명령하는 것이다.

 

DirectX, Open GL, Vulkan 같은 그래픽스 API를 사용해서 게임을 만들게 되면, 렌더링을 할 때 그래픽카드의 도움을 받아서 연산을 진행하게 된다. DirectX의 경우 그래픽카드의 연산을 활용하기 위해, DeviceContext에 쉐이더도 세팅하고 렌더타겟도 세팅하고 뷰포트토 세팅하고 많은 세팅을 하게 된다. 그렇게 그래픽카드에 여러 명령을 보내서 렌더링을 실행하게 된다. 

 

드로우 콜의 과정을 알아보자.

 

먼저, 우리는 API에서 제공해주는 기능을 토대로 버퍼와 쉐이더를 생성하게 된다. 그 과정에서, GPU의 메모리에 해당 정보가 저장되게 된다. 버텍스 버퍼, 인덱스 버퍼, 픽셀 쉐이더, 뎁스 스텐실 등의 모든 정보가 GPU메모리 상에 저장되는 것이다.

 

GPU는 이 정보들과 RenderState를 기준으로 렌더링을 진행하게 된다. 

 

RenderState란?

: 어떤 대상을 어떻게 그릴지에 대한 정보를 저장하고 있는 테이블.

 

예를 들어, A라는 캐릭터를 툰 쉐이더를 이용해 그리고자 한다고 해보자. 그렇다면, A의 버텍스 버퍼, 인덱스 버퍼, 사용할 텍스쳐, 사용할 버텍스 쉐이더, 픽셀 쉐이더, 알파블렌딩, 뎁스 스텐실 등등 수많은 정보가 필요하다.

 

이미 모든 정보는 GPU의 메모리에 저장되어 있지만, 지금 어떤 것을 이용해 그릴 지는 알 수가 없다.

그 것에 대한 정보를 저장하고 있는 것이 RenderState이다.

 

위의 그림처럼, 현재 렌더링을 실행할 때 어떤 것을 사용하여 렌더링을 할 지에 대한 정보를 담고 있는 것이 Render State이며, Render State의 각 값은 GPU 메모리의 주소값으로 보유하고 있게 된다.

 

그렇다면, Render State에서 현재 어떤 것을 가지고 렌더링을 실행할 것인가는 어떻게 정해지는 것일까?
CPU에서 명령을 하게 된다.

 

위처럼, 렌더링를 하기 전에 우리는 버텍스 버퍼를 세팅하고, 버텍스 쉐이더를 세팅하고, 픽셀 쉐이더를 세팅하는 등 현재 렌더링에서 사용되어야 할 머티리얼을 세팅해준다. 

 

이러한 함수들이 그래픽카드의 Render State를 바꾸라는 명령인 것이다.

이렇게 Render State를 의도한 대로 모두 변경하고 나면, Direct X에선 Draw함수를 실행하게 된다. (Draw, DrawAuto, DrawIndexed..)

 

해당 함수는 렌더링 세팅이 완료 되었으니, 이제 그림을 그려라! 라는 명령인 것이다.

이 명령을 DP Call 이라고 한다. (Draw Primitive Call)

 

DP Call 명령은 즉각적으로 GPU에서 처리하는 것은 아니고, CPU의 Command Buffer에 명령어를 저장해놓으면 GPU에선 명령 처리가 가능할 때, 가장 앞의 명령어를 꺼내서 실행하는 방식이다.

(GPU가 기존 작업을 끝낼 때까지 CPU가 대기할 필요도 없고, GPU도 명령받은 작업들을 효율적으로 처리할 수 있다.)

 

이처럼, Render State 변경 명렁을 내리고 DP Call을 하는 것을 통틀어 Draw Call이라고 부르는 것이다.

 

드로우 콜은 기본적으로 CPU에서 GPU로 보내는 명령이기 때문에, 명령을 GPU가 알아듣도록 변환할 필요가 있다. 이 과정에서 아주 많은 오버헤드가 발생한다고 한다.

 

드로우 콜의 오버헤드를 줄이는 법

많은 사람들이 착각하는 것중 하나가 한 메쉬의 버텍스 개수가 적어지면 드로우 콜의 오버헤드가 완화될 것이라고 기대하는 것이다. 앞에서 말했듯이 버텍스 버퍼는 이미 GPU 메모리에 저장되어 있고, 포인터를 이용해서 Render State를 변경하게 된다.

 

즉, CPU에서 GPU로 데이터를 보내는 과정은 버텍스의 개수와는 큰 관련이 없고, GPU에서 렌더링 연산을 수행하는 것과 연관이 있는 것이다. 버텍스의 개수는 CPU의 연산 부담을 늘리는 것이 아니라 GPU의 연산 부담을 늘리는 것이다

 

.드로우 콜의 오버헤드를 줄이기 위해선, 텍스쳐 퀄리티를 낮춘다거나 버텍스의 개수를 낮추는 등의 작업이 필요한 것이 아니라 드로우 콜 자체의 횟수를 줄이는 것이 중요하다.

 

드로우 콜은 무언가를 그려야 할 때마다 호출하게 된다. 예를 들어, 메쉬 3개로 이루어진 캐릭터는 3번의 드로우콜을 호출해야 하며, 반대로 메쉬가 1개인 캐릭터라도 외곽선 등의 추가적인 효과를 적용하기 위해 2번 이상 드로우 콜을 호출할 수도 있다.

 

즉, 메쉬의 개수 혹은 머티리얼의 개수에 따라 드로우 콜의 호출 빈도가 결정되는 것이다.

그렇다면, 무작정 매쉬의 수를 줄이거나 머티리얼의 수를 줄여버리면 될까?

물론 아니다. 그래픽 퀄리티를 조금이라도 올리기 위해 하드웨어 성능을 극한까지 사용하는 사람들도 있는 와중에, 매쉬와 머티리얼을 줄여서 퀄리티를 포기한다는 것은 올바른 해결법이 아니다.

 

그렇다면, 여러 개의 메쉬를 한 번의 드로우 콜로 해결할 수 있다면?
여러 번 그려야 하는 것을 한 번의 드로우 콜로 해결하고자 하는 것이 배칭이다.

 

배칭

하나의 캐릭터 모델을 눈 메쉬, 머리 메쉬, 상체 메쉬, 하의 메쉬로 쪼개놓았다고 해보자.

 

그렇다면, 4개의 메쉬는 다른 머티리얼을 쓸까? 물론 그런 상황도 있겠지만, 일반적으로는 모두 같은 머티리얼을 사용해서 렌더링하게 된다. 차이가 있다면, 텍스쳐는 모두 다른 텍스쳐를 사용할 것이다.

 

즉, 거의 모든 렌더 스테이트가 동일한데 텍스쳐 하나 때문에 4번의 드로우 콜을 보내야 하는 것이다.

 

하지만, 아틀라스 텍스쳐를 사용한다면?

 

아틀라스 텍스쳐란, 하나의 이미지 파일에 위처럼 여러 오브젝트에 사용될 텍스쳐를 모아놓은 것이다.

여기서 UV값을 이용하여 필요한 만큼 잘라서 텍스쳐를 메쉬에 입히는 것이다.

 

이러한 이미지 파일로 텍스쳐를 생성한 뒤, 부위별로 UV만 매칭해준다면 서로 다른 메쉬이지만 같은 텍스쳐를 사용하는 셈이 되는 것이다. 즉, 눈, 머리, 상의, 하체에 대해 렌더 스테이트가 완전히 동일해지는 것이다.

 

이러한 경우엔 4개의 메쉬를 하나로 합쳐버리더라도 품질의 손상 없이 렌더링이 가능해진다.

(메쉬를 나눠놓는 이유가 대부분 다른 텍스쳐를 입히기 위함이다.)

 

아틀라스 이미지를 활용하면, 여러 메쉬를 하나로 합치는 것이 가능하고 드로우 콜을 1번으로 압축하는게 가능하게 된다는 것이다!

 

즉 배칭이란, 같은 머티리얼을 사용하는 여러 메쉬를 하나로 합쳐 한 번의 드로우 콜로 렌더링하는 방식이라는 것이다.

 

배칭은 스태틱 배칭, 다이나믹 배칭 두 가지 종류가 있다.

 

스태틱 배칭은 로딩 타임에 미리 매쉬들을 배칭하는 것이다. 주로 움직이지 않는 오브젝트(맵, 장식물등)에 대해 사용한다.

여러 메쉬 중 같은 머티리얼을 사용하는 메쉬를 하나로 묶어, 한 번의 드로우콜로 렌더링을 해결하게 해준다.

 

다이나믹 배칭은 런타임에 배칭을 진행하는 것이다. 주로 움직이는 대상에 대해 사용한다.

실시간으로 오브젝트를 하나로 합치는 연산을 진행하며, 드로우 콜을 줄인다고 한다.

 

하지만, 이러한 배칭에도 문제가 있다.

 

스태틱 배칭의 문제점

 

1. 메모리 사용량이 증가한다.

: 덩어리로 합친 메쉬를 추가적으로 VRAM (GPU 메모리)에 올려놓아야 하기 때문에 메모리 사용량이 증가한다.

 

2. 컬링을 제대로 적용할 수 없어진다.

: 메쉬가 나누어져 있을 땐, 화면 밖으로 벗어난 메쉬만 렌더링 하지 않는 것이 가능하지만 하나로 합쳐버리는 순간  화면을 벗어나는 부분만 렌더링 하지 않는 것이 불가능해진다.

 

다이나믹 배칭의 문제점

 

1. 연산량이 너무 많다.

: 실시간으로 연산하기엔 연산량이 너무 많아, 버텍스의 수가 많으면 오히려 드로우 콜을 줄임으로써 얻는 이득보다 배칭으로 얻는 손해가 더 커질 수 있다고 한다.

 

배칭이라는 기술이 여러모로 효율적으로 보이는 것 같으면서도, 상황에 따라 비효율적일 수 있기 때문에 신중하게 사용하는 것이 좋을 것 같다.

사진 출처 : What is Rim Lighting? - NYIP Photo Articles

위의 사진을 보면, 동물의 테두리 부분이 밝게 빛나는 것을 볼 수 있다.

이처럼, 광원을 등지고 있을 때 물체의 테두리가 밝게 빛나는 것을 림라이트 혹은 역광이라고 한다.

 

이 림라이트는 현실에선 빛을 등지고 있을 때 나타나지만, 컴퓨터 그래픽스에선 특정 물체를 강조하거나 배경과 분리하기 위해 사용되기도 한다.

 

여기서 설명할 림라이트는 광원의 위치를 고려하지 않는 림라이트이다.

기본적으로 림라이트는 외곽선을 강조하기 위해 사용된다.

그러므로 물체의 외곽을 구별하는 것이 중요하다.

 

어떻게 외곽을 구분하는가?

위의 그림을 보자.빨간색 화살표는 해당 부위의 노말벡터를 표현한 것이다.

외곽이라고 하면, 카메라가 바라보고 있는 방향으로부터 수직에 가까운 경우가 많다.

위의 그림을 보면, 표현한 빨간 화살표는 사진 평면과 평행에 가까울 것이다.

 

하지만, 카메라가 바라보고 있는 동물의 몸통 한가운데에서 뻗어나오는 노말이라고 한다면, 카메라를 향하는 벡터와 유사할 것이다.

 

즉, 물체에서 카메라를 향하는 방향과 물체의 노말의 사잇각이 90도에 가까울수록 외곽이라고 판단할 수 있으며, 0도에 가까울수록 물체의 중심쪽이라고 판단할 수 있을 것이다.

 

두 벡터의 사잇각은 내적을 통해 구할 수 있다.

 

카메라를 향하는 방향 벡터  : v

물체의 노말 벡터 : n

 

위와 같이 가정한다면, 두 벡터의 사잇각은 dot(v, n)이 된다. (v , n 은 유닛벡터라고 가정하자.)

90도에 가까울수록 외곽이라고 했으니, dot(v,n)이 0에 가까울수록 외곽선이고 림라이트는 강해질 것이다.

 

그러므로 림라이트 공식은 아래와 같이 표현할 수 있다.

RimLight = (1 - dot(v, n));

 

이 RimLight를 픽셀의 컬러에 더해주면 물체의 테두리에 림라이트를 적용할 수 있다.

하지만, 위 공식 그대로만 계산하게 되면 몇 가지 문제가 있다.

 

 

 

이는 게임에서 위 공식대로 직접 적용한 것이다.

물론 림라이트가 잘 적용된 것을 볼 수 있다. 그런데 무슨 문제가 있다는 것일까?

엄밀히 말하자면, 문제가 있다기보단 빛의 강도나 범위 등을 조절할 수가 없다는 것이다.

 

상황에 따라 림라이트를 더 강하게 넣고 싶을 수도 있고, 더 약하게 넣고 싶을 수도 있는데 말이다.

그렇기 때문에 RimLight를 계산하는 식에는 몇 가지 변수가 더 추가된다.

 

먼저 강도이다. 외곽을 얼마나 더 빛나게 할 것인가를 조절하는 Strengh이다.

RimLight = (1 - dot(v, n)) * Strength;

 

이렇게 간단하게 값을 곱해주기만 하면 된다.

 

이렇게 Strength의 값에 따라 빛의 밝기가 달라지는 것을 알 수 있다.

다만, 이 경우엔 Strength가 커질수록 림라이트가 적용되는 범위또한 넓어지고 있다.

빛을 밝게 하되, 외곽선만 강조하고 싶은 경우엔 한 가지 변수를 더 추가할 수 있다.

 

Specular Light를 강조하기 위해서, 거듭제곱을 하는 것과 동일하다.

 

RimPower라는 변수를 연결한 뒤, 해당 변수의 값만큼 거듭제곱을 진행해주면 된다.

 

RimLight = (1 - dot(v, n));

RimLight = pow(RimLight, RimPower);

RimLight = RimLight * Strength;

 

위의 결과를 보면, Strength가 동일한 상태에서 RimPower를 조절하니 빛이 적용되는 범위가 외곽으로 집중되는 것을 확인할 수 있다.

CPU에서 GPU로 데이터를 보낼 땐, 그냥 데이터 덩어리로 데이터를 보낸다. C++ 코드에서 자료형이 어떻고, 변수 이름이 어떻고 이런건 아무 신경도 안쓰고, 그 안에 들어있는 데이터만 보내버리는 것이다.

 

쉐이더에서 상수버퍼의 자료형을 새로 선언해주는 이유도 이 때문이다. 들어온 데이터 중 몇 바이트를 어떤 자료형으로 사용할 것이고, 어떤 변수명을 사용할 것인지 등에 대한 정보는 CPU에서 보내주지 않기 때문에 쉐이더에서 직접 명시해야 하는 것이다.

 

이 과정에서 문제가 한 가지 생긴다. 상수버퍼는 반드시 데이터를 16바이트 단위로 정렬한다고 한다. 16바이트 경계를 넘어서면, 서로 침범할 수 없다고 한다. 데이터 덩어리가 들어오면, 우리가 C++ 코드에서 몇 바이트 단위로 데이터를 사용을 했건간에 무조건 16바이트 단위로 잘라버린다.

 

예를 들어, float3타입의 데이터 2개를 상수버퍼로 GPU에 보냈다고 해보자.

데이터를 보낼 땐 아래와 같이 보낼 것이다.

 

이렇게 데이터를 묶어서, 덩어리로 GPU에 보낼 것이다.

 

그리고 우리는 float3 자료형 2개를 보냈기 때문에, 쉐이더 코드에서도 float3 자료형으로 두 데이터를 받을 것이다.

A = {1.0f, 0.0f, 1.0f}, B = {0.7f, 0.4, 0.5f} 라고 한다면, 쉐이더에서도  A = {1.0f, 0.0f, 1.0f}, B = {0.7f, 0.4, 0.5f} 를 사용하길 원할 것이다.

 

하지만 실제로는 A = {1.0f, 0.0f, 1.0f}, B = {0.4f, 0.5f, 0.0f}가 되어버린다.

왜일까?

 

두 데이터를 보낼 때, 우리는 데이터가 연결된 상태의 24바이트로 데이터를 보냈다.

이를 GPU에서 저장할 땐, 16바이트 단위로 저장한다고 하였다. 이 때문에 데이터는 아래와 같이 저장된다.

B가 잘려서, 일부분이 A와 같은 영역에 저장되는 것이다.

이로 인해, B는 앞의 4바이트에 해당되는 0.7f데이터를 잃게되고 y와 z값이 x와 y값으로 변하게 된다.

 

A를 float3형으로 사용하게 되면 {1.0f, 0.0f, 1.0f}가 되지만, float4형으로 A를 받아보면 A는 {1.0f, 0.0f, 1.0f, 0.7f}로 B의 x값이 A의 z값에 저장되어 있음을 알 수 있다.

 

그렇기 때문에, 우리는 데이터를 의도한 대로 온전히 보내기 위해선 반드시 16바이트로 맞춰서 보내야 한다.

 

만약, float3 float3 float float 이렇게 4개의 데이터를 보낸다면, (float3 float) (float3 float)처럼 순서를 바꿔서 16바이트에 데이터가 포함되는 형태로 바꿔서 데이터를 보내야 한다는 것이다.

 

만약 따로 보낼 float형의 변수가 없다면, float4형의 변수를 선언한 뒤, xyz에 값을 저장하고 w를 비워서 데이터를 보내거나

의미없는 데이터를 float형으로 추가해서 함께 보낸다음 그냥 안쓰면 된다.

 

보통 16바이트를 채우기 위해 사용한 의미없는 데이터를 패딩이라고 한다.

 

(다른 사람들 쉐이더 코드 보면 float3 Position, float Padding 이런 코드가 자주 보인다. 이 때 padding은 16바이트를 맞추기 위해 사용한 의미없는 데이터라고 보면 된다.)

 

요약 : 상수버퍼에 데이터를 담아 GPU로 보낼 땐 반드시 16바이트 단위로 보내자.

 

우리가 코드를 작성하다 보면, 분기를 나눠야하는 경우가 상당히 많다.

분기를 나누는 이유가 여러가지가 있겠지만, 근본적으로는 상황에 맞게 필요한 연산만 실행하기 위해서일 것이다.

이는 최적화를 위해서 일 수도 있고, 올바른 결과물을 위해서일 수도 있을 것이다.

 

하지만, 만약 모든 분기가 다 실행된다면?

 

예를 들어 아래 코드를 보자.

if(현재 픽셀이 피부라면)
{
    Color = SkinShading();
}
else if(현재 픽셀이 옷이라면)
{
    Color = ClothShading();
}
else
{
    Color = HairShading();
}

 

뭐 이런 예시가 있다고 가정해보자. (일반적으로는 이런 분기는 나뉘지 않을 것 같긴 하다...  옷이랑 피부, 머리카락은 아예 다른 쉐이더를 쓰지 않을까?)

 

이 때, 우리가 기대하는 것은, 픽셀의 타입에 맞게 하나의 함수만 실행되는 것이다.

현재 픽셀이 피부라면, SkinShading만 실행하고, 옷이라면 ClothShading만 실행하고 그것도 아니라면 hairShading을 실행하는 것이다.

 

하지만 그래픽카드의 연산은 우리 의도대로 실행되지 않는다고 한다.

 

그래픽카드는 다중 병렬 연산을 통해 아주 고속으로 수많은 픽셀에 대한 연산을 처리해준다.

일반적으로는 32개나 64개의 레지스터를 묶어서 실행한다고 한다.

 

이 때, 병렬 연산을 극대화 하기 위해서는 모든 레지스터가 동일한 명령어를 처리하도록 하는 것이 가장 좋다고 한다.

즉, 분기 때문에 각 레지스터들이 다른 연산을 실행하게 되면 병렬 연산의 성능이 저하될 수 있다는 것이다.

 

이로 인해 레지스터간 처리해야 하는 명령어를 통일하기 위해 GPU에선 모든 분기를 다 실행한 뒤에 조건에 맞는 결과값만 반환하는 방식으로 실행한다고 한다.

 

하지만, 모든 분기를 다 실행하게 되면 이는 성능상에 엄청난 영향을 끼칠 수 있다. 특히나 if~else문 내부에 있는 연산이 무거울수록 그 정도는 더욱 커질것이다.

 

만약, 모든 픽셀이 동일한 분기로 진입하는 것이 확정된 상황이라면 모든 분기에 대한 연산을 다 실행하지 않고 특성 분기에 대해서만 실행하도록 최적화가 되어있다고는 한다.

 

하지만, 일반적인 상황에선 그렇지 않기 때문에 분기를 나누는 것이 아닌가?!

 

그러므로 우리는 쉐이더 내에서 if else 문을 사용할 때엔 주의를 하여서 사용해야 한다.

분기 안에 특히나 무거운 연산들이 포함되어 있을 때는 더욱 그렇다.

깊이 버퍼

 

깊이 버퍼란, 화면을 기준으로 얼마나 깊이있는가를 저장하는 버퍼이다.

우리가, 두 물체를 앞에서 보았을 때 위의 그림과 같이 놓여있다고 해보자.

두 물체는 언뜻 보기엔, 나란하게 놓여있는 것처럼 보인다. 하지만, 시야를 y축기준으로 90도 돌려서 물체를 좌측에서 관측해보자.

 

옆으로 돌려서 좌측에서 두 물체를 관측했더니, A가 B보다 더 뒤에 있음을 알 수 있었다.

즉, 현재 그래프에선 z값이 A가 더 큰 상황인 것이다.

 

정면에서 볼 때엔, A와 B가 나란하게 옆에 놓여있는 것처럼 보였지만 실제로는 A가 B보다 뒤에 존재했던 것이다.

 

이처럼, 각 물체는 z값을 기준으로 화면으로부터 얼마나 더 뒤에있는가를 수치로써 가지게 된다.

이 z값을 이용하여 물체가 얼마나 더 뒤에 있는지를 기록하는 버퍼를 깊이버퍼라고 한다.

 

그렇다면, 깊이버퍼가 왜 필요할까?

 

아래 그림처럼 두 물체가 겹쳐있다고 가정해보자. 

우리가 생각으로는 뒤에 있는 물체가 앞에 있는 물체에 의해 가려져야 하는게 당연한 결과이다.

그런데, 실제로는 그렇지 않다. 컴퓨터는 그저 물체의 색을 픽셀 배열에 찍어낼 뿐이기 때문에, 그리는 순서가 달라지면 위 그림과 같은 상황을 항상 보장하지 못한다.

 

만약, 초록색 구를 먼저 그리고 노란색 구를 그렸다면?

이런식으로 픽셀의 색을 덮어씌워버리면서, 실제로는 뒤에 있어야 할 물체가 더 앞에 있는 것처럼 렌더링이 되어버린다.

 

이러한 상황을 해결하기 위해 사용되는 것이 깊이버퍼이다.

한 픽셀에 이미 색이 칠해졌는데 그 위에 새로 색을 칠하게 될 때, 두 물체의 깊이버퍼를 비교하여 깊이버퍼의 값이 더 작은 쪽으로 픽셀을 칠하게 되는 것이다.

 

깊이버퍼를 사용하여 깊이체크를 하면, 물체를 렌더링하는 순서와 상관 없이 더 뒤에 있는 물체는 뒤에 있는 것처럼 그릴 수 있게 된다.

 

Z-Fighting

 

두물체가 겹칠 때, 깊이버퍼를 이용하여 물체중 앞에 칠해야 할 색을 구분한다고 하였다. 근데 만약 두 물체의 깊이버퍼 값이 동일하다면? 컴퓨터의 실수오차 문제로 실제로는 Z값이 다르게 계산된다 하더라도 컴퓨터에선 동일한 값으로 판단하는 경우도 있다. 혹은 실제로 Z값이 동일할 수도 있다. 이러한 경우에는 어떤 물체를 더 앞에 그려야하는지 판단하는데 오류가 발생해 앞뒤가 계속 바뀌면서 겹치는 부분이 깜빡깜빡거리는 상황이 발생한다. 이를 Z-Fighting라고 한다. 

 

그러니, 물체를 배치할 때엔 너무 겹쳐서 배치하지 않는 것이 좋다. 하지만, 어쩔 수 없이 겹쳐야 하는 경우도 있는데, 해결법은 카메라의 near, far 수치를 조정하는 방법이 가장 대표적이다.

 

깊이 버퍼 활용

 

깊이버퍼는 더 앞에 그려져야 할 물체를 판단하기 위한 목적말고도 사용되는 곳이 많다. 대표적으로 그림자가 있다.

그림자를 그리기 위해선, 광원으로부터 가려진 픽셀을 찾아내야 한다. 

 

그림을 보면, 광원이 물체를 비추고 그 물체의 뒤에 가려져 있어서 빛이 닿지 않는 바닥에 그림자가 지는 것을 볼 수 있다.

이 때, 그림자가 그려져야 할 부분은 광원 입장에선 물체보다 더 깊은 곳(안쪽)에 있는 픽셀이라고 판단할 수 있고 이를 깊이 버퍼에 저장하여 활용할 수 있다.

+ Recent posts