본문 바로가기
코딩/셰이더 프로그래밍 입문

[셰이더 프로그래밍] 04. 기초적인 조명 쉐이더 (난반사)

by witn331ss 2023. 6. 1.

빛을 구성하는 요소는 크게 난반사광 (Diffuse light) 정반사광 (specular light) 으로 나뉘어져 있다.

 

 

 

난반사광

수학적으로 난반사광은 어떻게 계산할 수 있을까?

게임에서 주로 사용하는 것은 람베르트 모델이다.

 

표면법선 과 입사광이 이루는 각의 코사인 값을 구하면 그것이 난반사광의 양이라고 얘기했다.

 

먼저 코사인 그래프를 살펴보자,

 

 

위 그래프를 보면 입사광과 표면 법선의 각도가 0일때, 결과(y축의 값) 가 1이 되었다.

그리고 각도가 늘어날수록 점점 작아지다가 90도가 되면 0이 된다. 여기서 더 나아가면 그 후로는 음수값이 된다.

 

실제 세계에선 빛의 각도에 따라 결과가 어떻게 될까?

 

앞의 그림에서 평면이 가장 빛날 때는 당연히 해가 중천일때다.

해가 저물어감에 따라 표면도 점점 어두워질 것이고 지평선을 넘어가는 순간 표면도 어두워질 것이다.

해가 지고나면 빛이 전혀 없기때문에 표면이 깜깜해질 것이고.

 

이 현상을 그래프로 그려보면 어떻게 될까?

 

법선과 해가 이루는 각도를 X축이라고 두고 표면의 밝기를 Y로 해보자.

여기서 Y축이 가지는 값의 범위는 0~1

 

위 그래프에서 -90 ~ 90 도 사이의 그래프에 물음표가 있는 이유는

각도가 줄어듦에 따라 얼마나 빠르게 표면이 어두워지는지 모르기 때문이다.

 

이 그래프는 우리가 이전에 보았던 코사인그래프와 비슷하게 생겼다.

그래프가 떨어지는 속도가 차이가 날 뿐.

 

그거 정밀하게 계산한거니까 믿어도 되겠지?

고맙다!!! 람베르트!!!!!!!!!!!

 

아무튼 람베르트 모델을 적용하면 손쉽게 난반사광을 구할 수 있다.

하지만 코사인 함수는 조금 무거운 함수여서 쉐이더에서 매번 호출하는 것이 조금 어렵다.

 

대른 대안으로는 dot product 을 이용해서 코사인을 대신 할 수 있다.

위 내적 공식에 따르면

 

두 벡터가 이루는 각의 코사인 값은

그 둘의 내적을 구한 뒤 두 벡터의 길이를 곱한 결과로 나눈 것과 같다.

여기서 두 벡터의 길이를 1로 만든면 공식을 더 간단하게 만들 수 있다.

두 백터가 이루는 각의 코사인 값은 두 벡터의 내적과 같다.

 

다만 우리 편의대로 벡터의 길이를 변경해도 될까?

이걸 다르게 표현하자면 '난반사광을 계산할 때 법선의 길이나 입사광벡터의 길이가 중요하다?' 인데

중요하지않다. 두 벡터가 이루는 각이 중요할 뿐 벡터의 길이는 결과에 아무런 영향을 미치지 않는다.

 

따라서 두 벡터의 길이를 각각 1로 만들어서 공식을 간단하게 만드는 것이 더 나아보인다.

 

내적이 코사인 함수보다 값싼 연산인걸 다시 느껴보자면

벡터 A의 성분을 (a,b,c)라고 하고 벡터 B의 성분을 (d,e,f)으로 두면 내적을 아주 간단하게 계산할 수 있다.

 

A · B = (a * d) + (b * e) + (c * f) 

확실히 코사인 함수보다 더 간단한 함수다.

 

난반사가 어떤식으로 이루어져 있는지 살펴 보았으니 렌더몽키에서 적용해보자.

람베트르 모델을 이용해 난반사광을 계산하려면 입사광의 벡터와 표면법선 벡터가 필요하다.

법선정보는 각 버텍스에 저장되어 있는게 보통임으로 따라서 버텍스 버퍼로부터 정보를 가져와야 한다.

 

앞 장에서 버텍스버퍼에서 UV좌표를 불러오기 위해 했던 별도의 설정을 한 것 처럼

Stream Mapping 에서 노멀을 꺼내주자.

 

이제 입사광의 벡터는 어떤식으로 구해야할까?

 

그냥 광원의 위치에서 현재 픽셀까지 직선을 하나 슥 그으면 그것이 입사광의 벡터이다.

따라서 광원의 위치를 알면 입사광의 벡터는 쉽게 구할 수 있다.

 

 

그렇다면 광원의 위치는? 

그냥 월드에서 (500,500,-500) 정도로 위치해두면 된다. 따라서 광원은 전역변수가 된다.

 

 

 

 

버텍스쉐이더

버텍스 쉐이더의 입력데이터

 

우선 이전에 작업했던 코드를 또 가져와서 설명해보겠다.

 

이제 여기서 법선을 더해줘야한다. 법선의 시맨틱은 NORMAL이고 법선은 3차원 공간에서의 방향을 나타냄으로 float3이 된다.

 

struct VS_INPUT
{
  float4 mPosition : POSITION;
  float3 mNormal : NORMAL;
};

 

버텍스 쉐이더 함수

이것 외에 버텍스쉐이더에서 따로 계산할 것들은 무엇이 있을까?

 

난반사광을 구하려면 입사광의 벡터와 법선의 내적을 구해야하는데

이런 일들을 버텍스쉐이더에서 과연 해줄것인가? 아니면 픽셀 쉐이더로 옮겨서 해야하나..?

 

사실 이 문제에 정답은 없다. 어느쪽에서든 계산할 수 있기 때문이다.

 

버텍스쉐이더에서 이 계산을 한다면 버텍스마다 두 벡터의 내적을 구한 뒤 그 결과를 VS_OUTPUT의 일부로 반환할 것이다. 그러면 보간기를 통해 보간된 값이 픽셀쉐이더에 전달되어 픽셀쉐이더는 그 값을 그냥 가져다 사용하면 된다.

 

반대로 픽셀쉐이더에서 계산한다고 하면 버텍스쉐이더가 법선정보를 VS_OUTPUT의 일부로 반환할 것이고

픽셀쉐이더는 보간된 법선을 읽어와 입사광의 벡터와 내적을 구하면 된다.

 

어느쪽에서 계산을 해도 차이가 없다면 당연히 최적화를 생각을 안할수가 없다.

 

어느쪽에서 계산을 해야 더 빨리될 것 인가는

각 함수가 호출되는 횟수를 따져보면 알 수 있다.

 

예시로 한 삼각형이 있다.

 

이 삼각형을 그릴때 버텍스 쉐이더는 정점 3개를 사용함으로 3번의 실행이 된다.

픽셀쉐이더는 화면에 차지한 픽셀 수 만큼 계산된다.

 

따라서 동일한 계산인 경우는 픽셀 쉐이더에서 하는 것이 아닌 버텍스쉐이더에서 하는 것이 훨씬 옳다.

.

.

.

.

.

.

먼저 입사광 벡터를 만들 것이다.

 

입사광 벡터는 광원의 위치에서 현재 위치까지 선을 그어주면 된다고 했었다.

이렇게 선을 긋는 것을 벡터의 뺄셈이라 한다. 즉 현재 위치에서 광원의 위치를 빼면 입사광의 벡터를 구할 수 있다.

 

한가지 주의해야할 사항은

3D 공간에서 올바른 결과를 얻으려면 모든 변수의 공간이 일치해야만 한다.

 

앞서 광원의 위치를 이미 월드 공간에 정의했었는데. 그럼 정점의 위치는 어느 공간에 있을까?

 

Input.mPosition 은 로컬 스페이스에 / Output.mPosition 은 투영공간에 있다.

지금 우리가 필요한 것은 월드공간인데 말이다(!!)

 

생각해보면 월드 행렬을 곱한 직후의 Output.mPosition이 바로 월드공간에서의 위치니까

이것에서 광원의 위치를 빼면 될것이다.

 

즉 월드 행렬을 곱하는 코드 바로 아래에 이 코드를 작성하면 된다.

이제 이 벡터의 길이를 1로 만들 것이다. 벡터의 길이가 1이면 내적만으로도 코사인 값을 구할 수 있다.

이렇게 벡터의 길이를 1로 만드는 과정을 정규화(normalize)이라고 한다.

 

수학적으로 단위벡터를 만들려면 각 성분을 벡터의 길이로 나누면 되는데 HLSL은 시맨틱을 제공하니 그걸 쓰자.

-

 

 

 

이제 입사광의 벡터가 준비되었으니 법선을 가져올 차례이다.

버텍스쉐이더의 입력데이터에 있는 법선을 그냥 사용해도 될까..?

 

이 법선은 어느 공간에 있는 것일까?

 

당연히 버텍스 버퍼에서 곧바로 오는 데이터임으로 물체공간일것이다.

그렇다면 이 법선을 월드공간으로 변환해줘야지만 제대로 된 난반사광을 구할 수 있다.

(3D 연산을 할 때는 모든 변수들이 존재하는 공간이 일치해야한다)

 

 

 

위 코드에서 Input.mNormal이 float3형이니 월드행렬을 그에 맞게 3x3 행렬로 바꿨다.

4x4 행렬에사 4번째 행(혹은 열)은 평행이동 값이므로 방향벡터에 영향을 주진 않는다.

(방향벡터에서 평행이동 값을 아무런 의미가 없다.)

 

또한 이 벡터 또한 단위벡터로 만들어준다.

 

 

-

 

 

이제 입사광의 벡터와 법선이 모두 준비되었음으로 내적을 구할 차례이다.

내적또한 HLSL에서 제공하는 내적 함수가 있으니 그걸 활용하면 되겠다.

 

 

위 코드를 보면 내적을 구한 결과를 mDiffuse 출력변수에 대입해줬다.

 

양수 입사광이 아닌 음수 입사광을 사용을 한 이유는

두 벡터의 내적을 구할 때 화살표의 밑동이 서로 만나야 하기 때문이다. lightdir를 쓴다면 입사광 벡터의 머리가 법선의 밑동과 만나므로 잘못된 결과를 발생시킨다.

 

또한 내적의 결과는 실수 하나인데 float3인 mDiffuse에 곧바로 대입한 것을 볼 수 있다.

이렇게 하면 float3의 세 성분이 모두 이 실수 값으로 채워진다.

 

dot(-lightDir, worldNormal).xyz 의 결과를 대입하는 것과 같은 결과를 보여준다.

 

그리고 결과반환

 

return Output;

 

 

전역변수

아무설명없이 빛의 위치를 전역변수로 선언하겠다. 라는 말을 피하기 위해 함수부터 살펴보았다.

 

그러니 빛의 전역변수를 추가해주자

 

 

 

버텍스 쉐이더의 출력데이터

 

버텍스 쉐이더 함수를 작성하면서 앞에서 살펴봤듯이

출력 데이터는 mPosition / mDiffuse이다.

 

위치야 뭐 float4 POSITION 시맨틱을 사용하면 되는데

mDiffuse는 어떤 형과 시맨틱을 써야할까?

 

두 벡터의 내적을 구하면 그 결과는 벡터로 나오는 것이 아닌 스칼라 하나로 나온다.

따라서 float 만 사용해도 문제없지만 나중에 이 값을 픽셀의 RGB로 출력할것이여서 

 

float3 을 사용하도록 한다. 

쉐이더 프로그래밍을 하다보면 용도에 딱 맞는 시맨틱이 없는 경우도 있는데

그럴 때에는 TEXCOORD 시맨틱을 사용하는게 일반적이다.

 

최소한 8개의 TEXCOORD가 존재해서 사용 시 모자랄 경우가 없기 때문.

 

 

 

 

 

픽셀쉐이더

계산은 모두 버텍스 쉐이더가 해줘서 픽셀 쉐이더가 할일은 그저 그 값을 가져와 출력하는 일 뿐이다.

 

내적을 결국 코사인 함수임으로 -1 ~ 1 사이의 결과 값을 가진다.

난반사광의 범위는 0 ~ 1 임으로 -1 이하의 값을 0으로 바꿔주면 된다.

 

saturate() 라는 함수는 0이하의 값을 0으로 1이상의 값을 1로 변경해준다.

그리고 이 함수는 성능에 영향을 미치지 않는 공짜함수다.